Summary
When selecting the kimi-k3 model through OpenRouter, every request fails with a 400 Bad Request because the tool schema sent to the provider contains an invalid null value inside an enum array. Moonshot AI (the upstream provider behind kimi-k3) rejects schemas where enum values do not match the declared type.
Error
ERROR: POST https://openrouter.ai/api/v1/chat/completions
Caused by:
0: 400 Bad Request Reason: {"error":{"message":"Provider returned error","code":400,"metadata":{"raw":"{\"error\":{\"message\":\"Invalid request: tools.function.parameters is not a valid moonshot flavored json schema, details: <At path 'properties.output_mode.enum': enum value (<nil>) does not match any type in [string]>\",\"type\":\"invalid_request_error\"}}","provider_name":"Moonshot AI","is_byok":false}},"user_id":"org_..."}
The key detail: At path 'properties.output_mode.enum': enum value (<nil>) does not match any type in [string].
Root Cause
The offending schema is produced by the FSSearch tool (and any other tool with an Option<Enum> field). The chain of events:
1. Schema generation appends null to enum arrays
crates/forge_domain/src/tools/catalog.rs:893 applies schemars' AddNullable transform, which for an Option<OutputMode> field produces:
"output_mode": {
"type": "string",
"nullable": true,
"enum": ["content", "files_with_matches", "count", null]
}
2. The null value is only stripped in strict mode
crates/forge_app/src/utils.rs:525-548 contains logic that converts a nullable enum into an anyOf and removes the null entry from the enum array (line 534: enum_values.retain(|v| !v.is_null())). However, this logic is gated behind strict_mode.
3. Strict mode is NOT enabled for OpenRouter-routed kimi/moonshot models
crates/forge_app/src/dto/openai/transformers/pipeline.rs:102-112 enables EnforceStrictToolSchema only for these provider IDs:
let strict_schema = EnforceStrictToolSchema
.pipe(EnforceStrictResponseFormatSchema)
.when(move |_| {
provider.id == ProviderId::FIREWORKS_AI
|| provider.id == ProviderId::FIREWORKS_AI_FIREPASS
|| provider.id == ProviderId::OPENCODE_ZEN
|| provider.id == ProviderId::OPENCODE_GO
|| provider.id == ProviderId::XAI
|| provider.id == ProviderId::KIMI_CODING
|| provider.id == ProviderId::MOONSHOT // <-- only direct provider
});
When kimi-k3 is accessed through OpenRouter, provider.id == ProviderId::OPEN_ROUTER, so EnforceStrictToolSchema is not applied. The schema is sent to Moonshot with the null enum value intact, and Moonshot rejects it.
Note that reasoning_content (pipeline.rs:78-84) already handles this correctly by using when_model("kimi") to match the model name regardless of provider — the strict-schema gate does not.
Reproduction
- Configure forge with an OpenRouter API key.
- Select model
kimi-k3 (routed via OpenRouter to Moonshot).
- Send any request that includes tools (e.g. a normal coding prompt). The very first request fails with the 400 error above.
Suggested Fix
There are a couple of options:
Option A (narrow): Extend the strict_schema condition to also match kimi/moonshot models routed through OpenRouter, mirroring the existing when_model("kimi") pattern used for reasoning_content:
let strict_schema = EnforceStrictToolSchema
.pipe(EnforceStrictResponseFormatSchema)
.when(move |request: &Request| {
provider.id == ProviderId::FIREWORKS_AI
|| provider.id == ProviderId::FIREWORKS_AI_FIREPASS
|| provider.id == ProviderId::OPENCODE_ZEN
|| provider.id == ProviderId::OPENCODE_GO
|| provider.id == ProviderId::XAI
|| provider.id == ProviderId::KIMI_CODING
|| provider.id == ProviderId::MOONSHOT
|| when_model("kimi")(request) // <-- cover OpenRouter-routed kimi
});
Option B (broader): Always strip null from enum arrays in enforce_strict_schema regardless of strict_mode, since a null literal inside a typed enum array (combined with nullable: true) is a non-standard representation that multiple providers reject. This would make the non-strict path safer for all OpenAI-compatible providers.
Environment
- Provider: OpenRouter
- Model:
kimi-k3 (Moonshot AI)
- Relevant code path:
crates/forge_app/src/dto/openai/transformers/pipeline.rs:102-112
Summary
When selecting the
kimi-k3model through OpenRouter, every request fails with a400 Bad Requestbecause the tool schema sent to the provider contains an invalidnullvalue inside anenumarray. Moonshot AI (the upstream provider behindkimi-k3) rejects schemas where enum values do not match the declared type.Error
The key detail:
At path 'properties.output_mode.enum': enum value (<nil>) does not match any type in [string].Root Cause
The offending schema is produced by the
FSSearchtool (and any other tool with anOption<Enum>field). The chain of events:1. Schema generation appends
nullto enum arrayscrates/forge_domain/src/tools/catalog.rs:893applies schemars'AddNullabletransform, which for anOption<OutputMode>field produces:2. The null value is only stripped in strict mode
crates/forge_app/src/utils.rs:525-548contains logic that converts a nullable enum into ananyOfand removes thenullentry from the enum array (line 534:enum_values.retain(|v| !v.is_null())). However, this logic is gated behindstrict_mode.3. Strict mode is NOT enabled for OpenRouter-routed kimi/moonshot models
crates/forge_app/src/dto/openai/transformers/pipeline.rs:102-112enablesEnforceStrictToolSchemaonly for these provider IDs:When
kimi-k3is accessed through OpenRouter,provider.id == ProviderId::OPEN_ROUTER, soEnforceStrictToolSchemais not applied. The schema is sent to Moonshot with thenullenum value intact, and Moonshot rejects it.Note that
reasoning_content(pipeline.rs:78-84) already handles this correctly by usingwhen_model("kimi")to match the model name regardless of provider — the strict-schema gate does not.Reproduction
kimi-k3(routed via OpenRouter to Moonshot).Suggested Fix
There are a couple of options:
Option A (narrow): Extend the
strict_schemacondition to also match kimi/moonshot models routed through OpenRouter, mirroring the existingwhen_model("kimi")pattern used forreasoning_content:Option B (broader): Always strip
nullfrom enum arrays inenforce_strict_schemaregardless ofstrict_mode, since anullliteral inside a typedenumarray (combined withnullable: true) is a non-standard representation that multiple providers reject. This would make the non-strict path safer for all OpenAI-compatible providers.Environment
kimi-k3(Moonshot AI)crates/forge_app/src/dto/openai/transformers/pipeline.rs:102-112