#3767·forgecode

Bug: kimi-k3 via OpenRouter fails with 400 — null enum value in tool schema rejected by Moonshot

Author: Wang-tianhaoCreated Jul 27, 2026Updated Sep 17, 2026
Labelsstate: inactive

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:

json
"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:

rust
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

  1. Configure forge with an OpenRouter API key.
  2. Select model kimi-k3 (routed via OpenRouter to Moonshot).
  3. 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:

rust
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