Bug: kimi-k3 via OpenRouter fails with 400 — null enum value in tool schema rejected by Moonshot
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
Source: tailcallhq/forgecode