feat: user-facing temperature setting#2275
Conversation
Add a configurable default sampling temperature (0.0–2.0) that users can set via the web settings UI or API. The setting flows into the main conversational agent loop via ReasoningContext, replacing the hardcoded 0.7 default. Per-request temperature (e.g. from the OpenAI-compatible endpoint) still takes precedence. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a user-configurable temperature setting for LLM inference, allowing users to adjust the sampling randomness between 0.0 and 2.0. The changes span the frontend UI (adding float-type setting support and translations), the settings storage, and the backend dispatcher which now applies this override during inference. Feedback was provided to clamp the temperature value in the backend to ensure robustness against invalid settings values that might cause API errors with LLM providers.
| context.available_tools.clone() | ||
| }; | ||
|
|
||
| let temperature = context.temperature.unwrap_or(0.7); |
There was a problem hiding this comment.
The temperature value should be clamped to a safe range (typically 0.0 to 2.0) before being passed to LLM providers. While the frontend UI enforces this range, the backend should also ensure robustness against malformed or manually injected settings values to prevent potential API errors from providers that have strict range requirements.
| let temperature = context.temperature.unwrap_or(0.7); | |
| let temperature = context.temperature.unwrap_or(0.7).clamp(0.0, 2.0); |
There was a problem hiding this comment.
Fixed in f3f2d19. Clamped at the use site in respond_with_tools (reasoning.rs) and also at the read site in the dispatcher (dispatcher.rs) when loading from the DB, for defense in depth.
Admin-set defaults now propagate to all members who haven't overridden
the value themselves. Three layers of change:
1. TenantScope::get_setting_with_admin_fallback() — checks user scope
first, then falls back to __admin__ scope. Used by the dispatcher
for temperature and selected_model.
2. Config::from_db_with_toml() — layers admin-scope settings between
TOML and per-user DB settings during resolution. Priority:
TOML < admin DB < per-user DB.
3. Settings API — GET/PUT/DELETE /api/settings/{key}?scope=admin lets
admins read/write to the shared default scope. Non-admins get 403.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Address review comment on #2275: the backend must guard against bad DB values and per-request overrides that bypass the frontend range enforcement. Some providers reject out-of-range temperatures outright. Clamped at both the read site (dispatcher reading DB settings) and the use site (reasoning.rs respond_with_tools) for defense in depth. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…emperature # Conflicts: # src/config/mod.rs
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
There was a problem hiding this comment.
Pull request overview
Adds a user-configurable default LLM sampling temperature and introduces an admin-default settings scope that members inherit unless overridden, wiring the value through the backend reasoning path and exposing it in the web UI.
Changes:
- Add
temperatureto persisted settings and propagate it intoReasoningContext→respond_with_tools()(replacing the hardcoded 0.7 default for conversational turns). - Introduce admin-scope settings fallback (
__admin__) for selected settings resolution and add?scope=adminsupport to settings GET/PUT/DELETE endpoints. - Extend the web structured settings renderer to support
floatinputs and add i18n labels for the new inference settings group.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/tenant.rs | Adds get_setting_with_admin_fallback() and unit tests for fallback behavior. |
| src/settings.rs | Introduces Settings.temperature: Option<f32>. |
| src/llm/reasoning.rs | Threads temperature through ReasoningContext and applies clamping before provider calls. |
| src/config/mod.rs | Adds admin-scope DB settings layer between TOML and per-user DB settings. |
| src/channels/web/types.rs | Adds query type for ?scope=admin on settings endpoints. |
| src/channels/web/responses_api.rs | Updates error message for unsupported per-request temperature. |
| src/channels/web/handlers/settings.rs | Implements scope=admin targeting for get/set/delete settings operations. |
| src/agent/dispatcher.rs | Loads selected_model and temperature from settings (with admin fallback) on iteration 0. |
| crates/ironclaw_gateway/static/i18n/en.js | Adds i18n strings for temperature + inference group. |
| crates/ironclaw_gateway/static/i18n/zh-CN.js | Adds i18n strings for temperature + inference group. |
| crates/ironclaw_gateway/static/i18n/ko.js | Adds i18n strings for temperature + inference group. |
| crates/ironclaw_gateway/static/app.js | Adds temperature setting UI and float support in structured settings rendering. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| && let Ok(admin_map) = store.get_all_settings(admin_scope).await | ||
| && !admin_map.is_empty() | ||
| { |
There was a problem hiding this comment.
Fixed in 857d3b2. Applied strip_admin_only_llm_keys to the admin-scope map under !is_operator before merging, so non-operators no longer inherit admin-only LLM endpoint keys (ollama_base_url, openai_compatible_base_url, llm_builtin_overrides, llm_custom_providers) from admin defaults. Regression test: re_resolve_llm_strips_admin_scope_admin_only_keys_for_non_operator in src/config/mod.rs.
| && let Ok(admin_map) = store.get_all_settings(admin_scope).await | ||
| && !admin_map.is_empty() | ||
| { |
There was a problem hiding this comment.
Fixed in 857d3b2. Same defense-in-depth strip applied to the admin-scope merge in re_resolve_llm_with_secrets when is_operator is false.
| // Temperature override from user or admin settings. | ||
| // Clamped to the supported range to guard against bad DB values. | ||
| if let Ok(Some(value)) = store.get_setting_with_admin_fallback("temperature").await | ||
| && let Some(t) = value.as_f64() | ||
| { | ||
| reason_ctx.temperature = Some((t as f32).clamp(0.0, 2.0)); | ||
| } |
There was a problem hiding this comment.
Fixed in 857d3b2. Extracted resolve_settings_temperature(current, settings_value) helper that returns None when a per-request value is already on the context, so explicit API-caller temperatures keep precedence over user/admin defaults. Clamping is preserved. Regression tests: resolve_settings_temperature_keeps_per_request_value plus three companion cases in src/agent/dispatcher.rs.
| /// Resolve the effective user_id for a settings operation. | ||
| /// | ||
| /// When `scope=admin`, the operation targets the shared admin-default scope | ||
| /// (`__admin__`). Only admin users may use this scope; non-admins get 403. | ||
| /// Without the scope parameter (or any other value), operations target the | ||
| /// calling user's own settings. | ||
| fn resolve_settings_scope( | ||
| user: &crate::channels::web::auth::UserIdentity, | ||
| query: &SettingScopeQuery, | ||
| ) -> Result<String, StatusCode> { | ||
| if query.scope.as_deref() == Some("admin") { | ||
| if user.role != "admin" { | ||
| tracing::warn!( | ||
| user_id = %user.user_id, | ||
| role = %user.role, | ||
| "Non-admin attempted to use scope=admin on settings endpoint" | ||
| ); | ||
| return Err(StatusCode::FORBIDDEN); | ||
| } | ||
| Ok(crate::tools::permissions::ADMIN_SETTINGS_USER_ID.to_string()) | ||
| } else { | ||
| Ok(user.user_id.clone()) | ||
| } | ||
| } |
There was a problem hiding this comment.
Acknowledged — this is a real inconsistency, but it's a pre-existing partial implementation of SettingScopeQuery rather than something introduced by this PR. Extending scope=admin to list/export/import touches the wire types and the frontend, so I'd prefer to keep this PR scoped to the temperature work and address it in a dedicated follow-up. Tracking it on my side.
serrrfirat
left a comment
There was a problem hiding this comment.
Paranoid review findings:
-
High -
src/tenant.rs:get_setting_with_admin_fallbacktreatsSome(Value::Null)as a user override and returns it before checking the__admin__scope. That conflicts with the settings layer semantics wherenullmeans default/absent, and bulk settings writes can persist null values. A user row liketemperature: nullorselected_model: nullwill silently block the admin default. Please skip null user values and continue to the admin lookup, with a regression test covering user null + admin value. -
Medium -
src/config/mod.rs/src/channels/web/handlers/settings.rs: admin-scoped secret-bearing settings are vaulted under the effective user id (__admin__), butre_resolve_llm_with_secretsmerges admin settings and then hydrates secrets only for the member/operatoruser_id. If admin-scoped provider settings are intended, sanitized provider config can merge without the admin API key. Please either disallow admin-scoped secret-bearing keys or hydrate admin-scope secrets before/alongside per-user secrets using the same precedence as settings.
- Strip admin-only LLM keys (ollama_base_url, openai_compatible_base_url, llm_builtin_overrides, llm_custom_providers) from the admin-scope merge in `Config::from_db_with_toml` and `Config::re_resolve_llm_with_secrets` when the resolving user is not an operator. Defense-in-depth so a non-admin member never inherits private/loopback provider endpoints from admin defaults. - Preserve per-request `reason_ctx.temperature` precedence in the dispatcher: settings-derived temperature only applies when no value was already set by the API caller. Extract `resolve_settings_temperature` helper for direct unit testing. - Add regression tests covering admin-scope strip behavior for both operator and non-operator paths, plus the temperature precedence rule including range clamping. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| user: &crate::channels::web::auth::UserIdentity, | ||
| query: &SettingScopeQuery, | ||
| ) -> Result<String, StatusCode> { | ||
| if query.scope.as_deref() == Some("admin") { |
There was a problem hiding this comment.
Medium Severity — Missing handler-level test for admin scope authorization
resolve_settings_scope correctly gates ?scope=admin to admins, but there's no test that drives the actual handler with a non-admin UserIdentity + scope=admin to assert a 403 response. The project's CLAUDE.md rule "Test Through the Caller, Not Just the Helper" applies here — a unit test on the pure function alone doesn't cover the wiring between the extractor, the scope resolver, and the handler.
Suggested fix: Add a test in the settings.rs test module that constructs a non-admin UserIdentity (role: "member"), passes Query(SettingScopeQuery { scope: Some("admin".into()) }), and asserts the handler returns 403. Pattern already exists in the module for similar tests (e.g. test_settings_delete_rejects_active_custom_backend).
| crate::config::helpers::strip_admin_only_llm_keys(&mut admin_map); | ||
| } | ||
| let admin_settings = Settings::from_db_map(&admin_map); | ||
| settings.merge_from(&admin_settings); |
There was a problem hiding this comment.
Medium Severity — Duplicated admin-scope fallback logic
The admin-scope fallback pattern (fetch admin map → strip admin-only keys for non-operators → merge into settings) is duplicated verbatim between from_db_with_toml (line ~290) and re_resolve_llm_with_secrets (line ~420). If one is updated without the other (e.g., a new strip rule or changed merge order), they silently diverge.
Suggested fix: Extract into a private helper like:
async fn apply_admin_scope_defaults(
store: &(dyn SettingsStore + Sync),
settings: &mut Settings,
user_id: &str,
is_operator: bool,
) { ... }Low-urgency — can be a follow-up.
serrrfirat
left a comment
There was a problem hiding this comment.
Reviewed as paranoid architect. Security model is sound — admin scope gated correctly, temperature clamped at multiple layers, internal system calls unaffected. Two medium findings posted as inline comments (missing handler-level test for admin scope authz, duplicated fallback logic in config). Neither is a blocker. Ship it.
Summary
temperatureto user settings (persisted via settings store, editable in web UI)ReasoningContext→respond_with_tools(), replacing the hardcoded 0.7 default for conversational turnsfloatinput type support to the structured settings renderer (with configurablestep/min/max)Details
Backend — temperature setting:
Settings.temperature: Option<f32>— new field under Inference Provider sectionReasoningContext.temperature: Option<f32>— propagated toCompletionRequest/ToolCompletionRequestChatDelegate::call_llm()readstemperaturefrom settings store on iteration 0 (same pattern asselected_model)Backend — admin-scoped settings fallback (multi-tenant):
TenantScope::get_setting_with_admin_fallback()— checks user scope, then falls back to__admin__scopeConfig::from_db_with_toml()— layers admin-scope settings between TOML and per-user DB. Priority:TOML < admin DB < per-user DBGET/PUT/DELETE /api/settings/{key}?scope=admin— lets admins read/write shared defaults. Non-admins get 403.selected_model,temperature, etc.Frontend:
renderStructuredSettingsRowto handlefloattype (parseFloat, configurablestep)Not in scope (follow-up):
IncomingMessage)?scope=adminvia API; UI can be added later)Test plan
cargo clippy --all --all-features— zero warningscargo test --lib -- reasoning— 176 tests passcargo test --lib -- dispatcher::tests— 62 tests passcargo test --lib -- settings— 114 tests passcargo test --lib -- tenant::tests— 10 tests pass (3 new admin-fallback tests)