From 4039bc000589ffc2ff3fef8f4aad9f0ee407750d Mon Sep 17 00:00:00 2001 From: Wang-tianhao <110560187+Wang-tianhao@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:55:29 -0700 Subject: [PATCH] fix: strip null enum values unconditionally in enforce_strict_schema When kimi-k3 is routed through OpenRouter, the EnforceStrictToolSchema transformer is not applied (it only matches the direct MOONSHOT/KIMI_CODING provider IDs). As a result, the tool schema sent to Moonshot retains a null literal inside the output_mode enum array, which Moonshot rejects with: 400 Bad Request: enum value () does not match any type in [string] The null entry originates from schemars' AddNullable transform applied in ToolCatalog::schema (catalog.rs:893) for Option fields. Fix: hoist the null-stripping logic out of the strict_mode branch so it runs unconditionally. The anyOf conversion remains strict-mode-only. This makes the non-strict path safe for all OpenAI-compatible providers, not just the ones explicitly gated into strict mode. Fixes #3767 --- crates/forge_app/src/utils.rs | 46 ++++++++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 4 deletions(-) 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!({