diff --git a/crates/forge_app/src/utils.rs b/crates/forge_app/src/utils.rs index 349157d793..d05ccef88b 100644 --- a/crates/forge_app/src/utils.rs +++ b/crates/forge_app/src/utils.rs @@ -522,6 +522,18 @@ pub fn enforce_strict_schema(schema: &mut serde_json::Value, strict_mode: bool) normalize_array_items(map, strict_mode); + // A `null` literal inside a typed `enum` array (produced by + // schemars' AddNullable transform for Option fields) is a + // non-standard representation that several OpenAI-compatible + // providers reject. For example, Moonshot AI (kimi) returns: + // "enum value () does not match any type in [string]". + // Strip null entries from enum arrays unconditionally so the + // schema is valid regardless of whether strict mode is applied + // (e.g. when kimi is routed through OpenRouter). + if let Some(serde_json::Value::Array(enum_values)) = map.get_mut("enum") { + enum_values.retain(|v| !v.is_null()); + } + if strict_mode && map .get("nullable") @@ -530,10 +542,6 @@ pub fn enforce_strict_schema(schema: &mut serde_json::Value, strict_mode: bool) { map.remove("nullable"); - if let Some(serde_json::Value::Array(enum_values)) = map.get_mut("enum") { - enum_values.retain(|v| !v.is_null()); - } - let description = map.remove("description"); let non_null_branch = serde_json::Value::Object(std::mem::take(map)); let null_branch = serde_json::json!({"type": "null"}); @@ -1327,6 +1335,36 @@ mod tests { assert!(schema["properties"]["output_mode"].get("anyOf").is_none()); } + #[test] + fn test_null_enum_values_stripped_in_non_strict_mode() { + // schemars' AddNullable transform appends `null` to enum arrays for + // Option fields. Several OpenAI-compatible providers (e.g. + // Moonshot/kimi via OpenRouter) reject a null literal inside a typed + // enum array. Null entries must be stripped even in non-strict mode so + // the schema is valid regardless of the provider routing. + let mut schema = json!({ + "type": "object", + "properties": { + "output_mode": { + "nullable": true, + "type": "string", + "enum": ["content", "files_with_matches", "count", null] + } + } + }); + + enforce_strict_schema(&mut schema, false); + + // The null entry is removed, but nullable is preserved (non-strict mode + // does not convert to anyOf). + assert_eq!( + schema["properties"]["output_mode"]["enum"], + json!(["content", "files_with_matches", "count"]) + ); + assert_eq!(schema["properties"]["output_mode"]["nullable"], json!(true)); + assert!(schema["properties"]["output_mode"].get("anyOf").is_none()); + } + #[test] fn test_schema_valued_additional_properties_is_normalized() { let mut schema = json!({