From 19fe8cc796612ab695eee035be259526de028721 Mon Sep 17 00:00:00 2001 From: hugocasa Date: Fri, 18 Sep 2026 18:33:40 +0200 Subject: [PATCH] fix: bound per-model context windows on both AI config write paths Co-Authored-By: Claude Opus 5 --- backend/windmill-ai/src/ai_types.rs | 63 +++++++++++++++++++++++- backend/windmill-api-settings/src/lib.rs | 2 + backend/windmill-api/src/ai.rs | 8 +++ backend/windmill-api/src/workspaces.rs | 1 + 4 files changed, 72 insertions(+), 2 deletions(-) diff --git a/backend/windmill-ai/src/ai_types.rs b/backend/windmill-ai/src/ai_types.rs index 69c8ac4538..56c0763423 100644 --- a/backend/windmill-ai/src/ai_types.rs +++ b/backend/windmill-ai/src/ai_types.rs @@ -210,7 +210,9 @@ pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(), return Err(format!("Price override for {} is not an object", key)); }; for field in ["input", "output", "cache_read", "cache_write"] { - let Some(rate) = price.get(field) else { continue }; + let Some(rate) = price.get(field) else { + continue; + }; let rate = rate .as_f64() .filter(|r| r.is_finite() && *r >= 0.0 && *r <= MAX_MODEL_RATE); @@ -223,9 +225,66 @@ pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(), } for required in ["input", "output"] { if !price.contains_key(required) { - return Err(format!("Price override for {} is missing {}", key, required)); + return Err(format!( + "Price override for {} is missing {}", + key, required + )); } } } Ok(()) } + +// ============================================================================ +// Context windows +// ============================================================================ + +/// The chat compacts its history against `context_window_per_model`, so a window near +/// or below zero would compact on every turn or drop the whole history. +pub const MIN_CONTEXT_WINDOW: i64 = 1000; +pub const MAX_CONTEXT_WINDOW: i64 = 10_000_000; + +pub fn validate_context_window(key: &str, tokens: i64) -> Result<(), String> { + if !(MIN_CONTEXT_WINDOW..=MAX_CONTEXT_WINDOW).contains(&tokens) { + return Err(format!( + "Context window for {key} must be between {MIN_CONTEXT_WINDOW} and {MAX_CONTEXT_WINDOW} tokens" + )); + } + Ok(()) +} + +/// The untyped counterpart for the instance config, for the reason given on +/// `validate_model_pricing_json`. +pub fn validate_context_windows_json(ai_config: &serde_json::Value) -> Result<(), String> { + let windows = match ai_config.get("context_window_per_model") { + None | Some(serde_json::Value::Null) => return Ok(()), + Some(v) => v + .as_object() + .ok_or_else(|| "context_window_per_model must be an object".to_string())?, + }; + for (key, tokens) in windows { + let tokens = tokens + .as_i64() + .ok_or_else(|| format!("Context window for {key} must be an integer"))?; + validate_context_window(key, tokens)?; + } + Ok(()) +} + +#[cfg(test)] +mod context_window_tests { + use super::validate_context_windows_json; + use serde_json::json; + + #[test] + fn instance_context_windows_are_bounded() { + let check = |w| validate_context_windows_json(&json!({ "context_window_per_model": w })); + assert!(validate_context_windows_json(&json!({})).is_ok()); + assert!(check(json!({ "customai:m": 32000 })).is_ok()); + assert!(check(json!({ "customai:m": 0 })).is_err()); + assert!(check(json!({ "customai:m": -5 })).is_err()); + assert!(check(json!({ "customai:m": 5_000_000_000i64 })).is_err()); + assert!(check(json!({ "customai:m": "32000" })).is_err()); + assert!(check(json!([32000])).is_err()); + } +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 3bbaf888e2..a79dcb9d1e 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -945,6 +945,8 @@ async fn run_setting_pre_write_hook( AI_CONFIG_SETTING => { windmill_ai::ai_types::validate_model_pricing_json(value) .map_err(error::Error::BadRequest)?; + windmill_ai::ai_types::validate_context_windows_json(value) + .map_err(error::Error::BadRequest)?; } AUTOMATE_USERNAME_CREATION_SETTING => { if value.as_bool().unwrap_or(false) { diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 1fe88a3d8d..7ae12ef831 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -512,6 +512,14 @@ impl AIConfig { Ok(()) } + pub fn validate_context_windows(&self) -> Result<()> { + for (key, tokens) in self.context_window_per_model.iter().flatten() { + windmill_ai::ai_types::validate_context_window(key, i64::from(*tokens)) + .map_err(Error::BadRequest)?; + } + Ok(()) + } + pub fn validate_sessions_retention(&self) -> Result<()> { match self.sessions_retention_days { Some(days) if !(1..=MAX_SESSIONS_RETENTION_DAYS).contains(&days) => { diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index a0a27eab4c..4ea72b1c29 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -108,6 +108,7 @@ async fn edit_copilot_config( } ai_config.validate_model_pricing()?; + ai_config.validate_context_windows()?; ai_config.validate_sessions_retention()?; let mut tx = db.begin().await?;