Skip to content

Commit 88b87c0

Browse files
feat: user-facing temperature setting (#2275)
* feat: user-facing temperature setting for LLM requests 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> * feat: admin-scoped settings fallback for multi-tenant 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> * fix: clamp temperature to 0.0-2.0 before reaching provider 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> * fix: address PR #2275 review feedback - 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> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cd9b60c commit 88b87c0

File tree

12 files changed

+367
-28
lines changed

12 files changed

+367
-28
lines changed

crates/ironclaw_gateway/static/app.js

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7084,6 +7084,12 @@ function loadSettingsSubtab(subtab) {
70847084
// --- Structured Settings Definitions ---
70857085

70867086
var INFERENCE_SETTINGS = [
7087+
{
7088+
group: 'cfg.group.inference',
7089+
settings: [
7090+
{ key: 'temperature', label: 'cfg.temperature.label', description: 'cfg.temperature.desc', type: 'float', min: 0, max: 2, step: 0.1 },
7091+
]
7092+
},
70877093
{
70887094
group: 'cfg.group.embeddings',
70897095
settings: [
@@ -7429,25 +7435,25 @@ function renderStructuredSettingsRow(def, value, activeValue) {
74297435
return function() { saveSetting(k, el.value === '' ? null : el.value); };
74307436
})(def.key, sel));
74317437
inputWrap.appendChild(sel);
7432-
} else if (def.type === 'number') {
7438+
} else if (def.type === 'number' || def.type === 'float') {
74337439
var numInp = document.createElement('input');
74347440
numInp.type = 'number';
7435-
numInp.step = '1';
7441+
numInp.step = def.step !== undefined ? String(def.step) : (def.type === 'float' ? 'any' : '1');
74367442
numInp.className = 'settings-input';
74377443
numInp.setAttribute('aria-label', ariaLabel);
74387444
numInp.value = (value === null || value === undefined) ? '' : value;
74397445
if (!value && value !== 0) numInp.placeholder = placeholderText;
74407446
if (def.min !== undefined) numInp.min = def.min;
74417447
if (def.max !== undefined) numInp.max = def.max;
7442-
numInp.addEventListener('change', (function(k, el) {
7448+
numInp.addEventListener('change', (function(k, el, isFloat) {
74437449
return function() {
74447450
if (el.value === '') return saveSetting(k, null);
7445-
var parsed = parseInt(el.value, 10);
7451+
var parsed = isFloat ? parseFloat(el.value) : parseInt(el.value, 10);
74467452
if (isNaN(parsed)) return;
74477453
el.value = parsed;
74487454
saveSetting(k, parsed);
74497455
};
7450-
})(def.key, numInp));
7456+
})(def.key, numInp, def.type === 'float'));
74517457
inputWrap.appendChild(numInp);
74527458
} else if (def.type === 'list') {
74537459
var listInp = document.createElement('input');

crates/ironclaw_gateway/static/i18n/en.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,9 @@ I18n.register('en', {
521521
'cfg.llm_backend.desc': 'LLM inference provider',
522522
'cfg.selected_model.label': 'Model',
523523
'cfg.selected_model.desc': 'Model name or ID for the selected backend',
524+
'cfg.temperature.label': 'Temperature',
525+
'cfg.temperature.desc': 'Default sampling temperature (0.0–2.0). Lower = more deterministic, higher = more creative',
526+
'cfg.group.inference': 'Inference',
524527
'cfg.ollama_base_url.label': 'Ollama URL',
525528
'cfg.ollama_base_url.desc': 'Base URL for Ollama API',
526529
'cfg.openai_compatible_base_url.label': 'OpenAI-compatible URL',

crates/ironclaw_gateway/static/i18n/ko.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,9 @@ I18n.register('ko', {
520520
'cfg.llm_backend.desc': 'LLM 추론 공급자',
521521
'cfg.selected_model.label': '모델',
522522
'cfg.selected_model.desc': '선택한 백엔드의 모델 이름 또는 ID',
523+
'cfg.temperature.label': '온도',
524+
'cfg.temperature.desc': '기본 샘플링 온도 (0.0–2.0). 낮을수록 결정적, 높을수록 창의적',
525+
'cfg.group.inference': '추론',
523526
'cfg.ollama_base_url.label': 'Ollama URL',
524527
'cfg.ollama_base_url.desc': 'Ollama API의 베이스 URL',
525528
'cfg.openai_compatible_base_url.label': 'OpenAI 호환 URL',

crates/ironclaw_gateway/static/i18n/zh-CN.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,9 @@ I18n.register('zh-CN', {
520520
'cfg.llm_backend.desc': 'LLM 推理提供商',
521521
'cfg.selected_model.label': '模型',
522522
'cfg.selected_model.desc': '所选后端的模型名称或 ID',
523+
'cfg.temperature.label': '温度',
524+
'cfg.temperature.desc': '默认采样温度(0.0–2.0)。越低越确定性,越高越有创意',
525+
'cfg.group.inference': '推理',
523526
'cfg.ollama_base_url.label': 'Ollama URL',
524527
'cfg.ollama_base_url.desc': 'Ollama API 基础 URL',
525528
'cfg.openai_compatible_base_url.label': 'OpenAI 兼容 URL',

src/agent/dispatcher.rs

Lines changed: 82 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,24 @@ fn selected_model_override(value: &serde_json::Value) -> Option<String> {
2727
crate::llm::normalized_model_override(value.as_str()).map(str::to_string)
2828
}
2929

30+
/// Decide whether a settings-derived temperature should override the
31+
/// per-request value already on the reasoning context.
32+
///
33+
/// Returns `Some(new_value)` only when there is no per-request value yet
34+
/// AND the settings value parses as a number. The result is clamped to the
35+
/// supported `[0.0, 2.0]` range to guard against bad DB values.
36+
fn resolve_settings_temperature(
37+
current: Option<f32>,
38+
settings_value: Option<&serde_json::Value>,
39+
) -> Option<f32> {
40+
if current.is_some() {
41+
return None;
42+
}
43+
settings_value
44+
.and_then(|v| v.as_f64())
45+
.map(|t| (t as f32).clamp(0.0, 2.0))
46+
}
47+
3048
/// Result of the agentic loop execution.
3149
pub(super) enum AgenticLoopResult {
3250
/// Completed with a response.
@@ -583,16 +601,31 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
583601
.into());
584602
}
585603

586-
// Apply per-user model override from settings (first iteration only
604+
// Apply per-user overrides from settings (first iteration only
587605
// to avoid repeated DB lookups within the same agentic loop).
588-
// Uses "selected_model" — the same key the /model command persists to
589-
// via SettingsStore (per-user scoped via TenantScope).
606+
// Uses admin-fallback so admin-set defaults propagate to members
607+
// who haven't overridden the value themselves.
590608
if iteration == 0
591609
&& let Some(store) = self.tenant.store()
592-
&& let Ok(Some(value)) = store.get_setting("selected_model").await
593-
&& let Some(model) = selected_model_override(&value)
594610
{
595-
reason_ctx.model_override = Some(model);
611+
// Model override: "selected_model" — the same key the /model command
612+
// persists to via SettingsStore (per-user scoped via TenantScope).
613+
if let Ok(Some(value)) = store
614+
.get_setting_with_admin_fallback("selected_model")
615+
.await
616+
&& let Some(model) = selected_model_override(&value)
617+
{
618+
reason_ctx.model_override = Some(model);
619+
}
620+
621+
// Temperature override from user or admin settings. Per-request
622+
// values already on the context take precedence over settings.
623+
if let Ok(setting) = store.get_setting_with_admin_fallback("temperature").await
624+
&& let Some(t) =
625+
resolve_settings_temperature(reason_ctx.temperature, setting.as_ref())
626+
{
627+
reason_ctx.temperature = Some(t);
628+
}
596629
}
597630

598631
let output = match reasoning.respond_with_tools(reason_ctx).await {
@@ -1693,7 +1726,8 @@ mod tests {
16931726

16941727
use super::{
16951728
capture_auth_prompt, check_auth_required, extract_auth_prompt, parse_auth_result,
1696-
persist_selected_auth_prompt, restore_selected_auth_prompt, selected_model_override,
1729+
persist_selected_auth_prompt, resolve_settings_temperature, restore_selected_auth_prompt,
1730+
selected_model_override,
16971731
};
16981732
use crate::agent::session::PendingAuthPrompt;
16991733

@@ -3041,6 +3075,47 @@ mod tests {
30413075
}
30423076
}
30433077

3078+
#[test]
3079+
fn resolve_settings_temperature_keeps_per_request_value() {
3080+
// Regression: a per-request temperature already on the context must
3081+
// win over a settings-derived value, otherwise API callers cannot
3082+
// override the user/admin default for a single call.
3083+
assert_eq!(
3084+
resolve_settings_temperature(Some(0.42), Some(&serde_json::json!(1.5))),
3085+
None,
3086+
"must not return Some when current is set"
3087+
);
3088+
}
3089+
3090+
#[test]
3091+
fn resolve_settings_temperature_uses_settings_when_unset() {
3092+
assert_eq!(
3093+
resolve_settings_temperature(None, Some(&serde_json::json!(0.9))),
3094+
Some(0.9),
3095+
);
3096+
}
3097+
3098+
#[test]
3099+
fn resolve_settings_temperature_clamps_out_of_range_db_value() {
3100+
assert_eq!(
3101+
resolve_settings_temperature(None, Some(&serde_json::json!(9.0))),
3102+
Some(2.0),
3103+
);
3104+
assert_eq!(
3105+
resolve_settings_temperature(None, Some(&serde_json::json!(-1.0))),
3106+
Some(0.0),
3107+
);
3108+
}
3109+
3110+
#[test]
3111+
fn resolve_settings_temperature_returns_none_when_settings_missing() {
3112+
assert_eq!(resolve_settings_temperature(None, None), None);
3113+
assert_eq!(
3114+
resolve_settings_temperature(None, Some(&serde_json::json!("not-a-number"))),
3115+
None,
3116+
);
3117+
}
3118+
30443119
#[test]
30453120
fn selected_model_override_ignores_default_sentinel() {
30463121
assert_eq!(selected_model_override(&serde_json::json!("default")), None);

src/channels/web/handlers/settings.rs

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::sync::Arc;
44

55
use axum::{
66
Json,
7-
extract::{Path, State},
7+
extract::{Path, Query, State},
88
http::StatusCode,
99
};
1010
use secrecy::SecretString;
@@ -17,6 +17,31 @@ use crate::secrets::{CreateSecretParams, SecretsStore};
1717
/// Sentinel value the frontend sends to mean "key is unchanged, don't touch it".
1818
const API_KEY_UNCHANGED: &str = "••••••••";
1919

20+
/// Resolve the effective user_id for a settings operation.
21+
///
22+
/// When `scope=admin`, the operation targets the shared admin-default scope
23+
/// (`__admin__`). Only admin users may use this scope; non-admins get 403.
24+
/// Without the scope parameter (or any other value), operations target the
25+
/// calling user's own settings.
26+
fn resolve_settings_scope(
27+
user: &crate::channels::web::auth::UserIdentity,
28+
query: &SettingScopeQuery,
29+
) -> Result<String, StatusCode> {
30+
if query.scope.as_deref() == Some("admin") {
31+
if user.role != "admin" {
32+
tracing::warn!(
33+
user_id = %user.user_id,
34+
role = %user.role,
35+
"Non-admin attempted to use scope=admin on settings endpoint"
36+
);
37+
return Err(StatusCode::FORBIDDEN);
38+
}
39+
Ok(crate::tools::permissions::ADMIN_SETTINGS_USER_ID.to_string())
40+
} else {
41+
Ok(user.user_id.clone())
42+
}
43+
}
44+
2045
pub async fn settings_list_handler(
2146
State(state): State<Arc<GatewayState>>,
2247
AuthenticatedUser(user): AuthenticatedUser,
@@ -68,13 +93,16 @@ pub async fn settings_get_handler(
6893
State(state): State<Arc<GatewayState>>,
6994
AuthenticatedUser(user): AuthenticatedUser,
7095
Path(key): Path<String>,
96+
Query(query): Query<SettingScopeQuery>,
7197
) -> Result<Json<SettingResponse>, StatusCode> {
98+
let effective_user_id = resolve_settings_scope(&user, &query)?;
99+
72100
let store = state
73101
.store
74102
.as_ref()
75103
.ok_or(StatusCode::SERVICE_UNAVAILABLE)?;
76104
let row = store
77-
.get_setting_full(&user.user_id, &key)
105+
.get_setting_full(&effective_user_id, &key)
78106
.await
79107
.map_err(|e| {
80108
tracing::error!("Failed to get setting '{}': {}", key, e);
@@ -88,7 +116,7 @@ pub async fn settings_get_handler(
88116
"llm_builtin_overrides" | "llm_custom_providers"
89117
) {
90118
let mut map = std::collections::HashMap::from([(key.clone(), row.value.clone())]);
91-
annotate_secret_key_presence(&state, &user.user_id, &mut map).await;
119+
annotate_secret_key_presence(&state, &effective_user_id, &mut map).await;
92120
mask_settings_api_keys(&mut map);
93121
map.remove(&key).unwrap_or(row.value)
94122
} else {
@@ -106,8 +134,10 @@ pub async fn settings_set_handler(
106134
State(state): State<Arc<GatewayState>>,
107135
AuthenticatedUser(user): AuthenticatedUser,
108136
Path(key): Path<String>,
137+
Query(query): Query<SettingScopeQuery>,
109138
Json(body): Json<SettingWriteRequest>,
110139
) -> Result<StatusCode, StatusCode> {
140+
let effective_user_id = resolve_settings_scope(&user, &query)?;
111141
ensure_setting_write_allowed(&user, &key)?;
112142

113143
let store = state
@@ -117,24 +147,24 @@ pub async fn settings_set_handler(
117147

118148
// Guard: cannot remove a custom provider that is currently active.
119149
if key == "llm_custom_providers" {
120-
guard_active_provider_not_removed(store, &user.user_id, &body.value).await?;
150+
guard_active_provider_not_removed(store, &effective_user_id, &body.value).await?;
121151
validate_custom_providers(&body.value)?;
122152
}
123153

124154
// Extract API keys from LLM settings and vault them in the secrets store.
125155
// The sanitized value has api_key fields removed (stored encrypted instead).
126156
let sanitized_value = match key.as_str() {
127157
"llm_builtin_overrides" => {
128-
extract_builtin_override_keys(&state, &user.user_id, &body.value).await?
158+
extract_builtin_override_keys(&state, &effective_user_id, &body.value).await?
129159
}
130160
"llm_custom_providers" => {
131-
extract_custom_provider_keys(&state, &user.user_id, &body.value).await?
161+
extract_custom_provider_keys(&state, &effective_user_id, &body.value).await?
132162
}
133163
_ => body.value.clone(),
134164
};
135165

136166
store
137-
.set_setting(&user.user_id, &key, &sanitized_value)
167+
.set_setting(&effective_user_id, &key, &sanitized_value)
138168
.await
139169
.map_err(|e| {
140170
tracing::error!("Failed to set setting '{}': {}", key, e);
@@ -241,7 +271,9 @@ pub async fn settings_delete_handler(
241271
State(state): State<Arc<GatewayState>>,
242272
AuthenticatedUser(user): AuthenticatedUser,
243273
Path(key): Path<String>,
274+
Query(query): Query<SettingScopeQuery>,
244275
) -> Result<StatusCode, StatusCode> {
276+
let effective_user_id = resolve_settings_scope(&user, &query)?;
245277
ensure_setting_write_allowed(&user, &key)?;
246278

247279
let store = state
@@ -252,12 +284,16 @@ pub async fn settings_delete_handler(
252284
// Guard: deleting llm_custom_providers is equivalent to setting it to [].
253285
// Reject if the active backend is a custom provider that would be removed.
254286
if key == "llm_custom_providers" {
255-
guard_active_provider_not_removed(store, &user.user_id, &serde_json::Value::Array(vec![]))
256-
.await?;
287+
guard_active_provider_not_removed(
288+
store,
289+
&effective_user_id,
290+
&serde_json::Value::Array(vec![]),
291+
)
292+
.await?;
257293
}
258294

259295
store
260-
.delete_setting(&user.user_id, &key)
296+
.delete_setting(&effective_user_id, &key)
261297
.await
262298
.map_err(|e| {
263299
tracing::error!("Failed to delete setting '{}': {}", key, e);
@@ -1217,6 +1253,7 @@ mod tests {
12171253
workspace_read_scopes: Vec::new(),
12181254
}),
12191255
Path("ollama_base_url".to_string()),
1256+
Query(SettingScopeQuery::default()),
12201257
Json(SettingWriteRequest {
12211258
value: serde_json::json!("http://192.168.1.50:11434"),
12221259
}),
@@ -1240,6 +1277,7 @@ mod tests {
12401277
workspace_read_scopes: Vec::new(),
12411278
}),
12421279
Path("llm_custom_providers".to_string()),
1280+
Query(SettingScopeQuery::default()),
12431281
)
12441282
.await
12451283
.unwrap_err();

src/channels/web/responses_api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -610,7 +610,7 @@ pub async fn create_response_handler(
610610
if req.temperature.is_some() {
611611
return Err(api_error(
612612
StatusCode::BAD_REQUEST,
613-
"The 'temperature' field is not yet supported",
613+
"Per-request 'temperature' is not supported on this endpoint; configure the default via settings",
614614
"invalid_request_error",
615615
));
616616
}

src/channels/web/types.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,14 @@ pub struct SettingWriteRequest {
911911
pub value: serde_json::Value,
912912
}
913913

914+
/// Query parameters for settings endpoints.
915+
/// `?scope=admin` writes to / reads from the admin-default scope.
916+
#[derive(Debug, Default, Deserialize)]
917+
pub struct SettingScopeQuery {
918+
#[serde(default)]
919+
pub scope: Option<String>,
920+
}
921+
914922
#[derive(Debug, Deserialize)]
915923
pub struct SettingsImportRequest {
916924
pub settings: std::collections::HashMap<String, serde_json::Value>,

0 commit comments

Comments
 (0)