Skip to content

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

Description

@Wang-tianhao

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

  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:

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions