Skip to content

[bug] /model <bare-id> routes to the wrong endpoint when default_provider is a named [providers.<name>] profile #1163

Description

@D0nnieD4rk0

Summary

When default_provider is a user-defined named [providers.<name>] profile (any non-built-in openai-compatible profile, e.g. kilocode, pollinations), typing /model <bare-id> for a model id that resembles an OpenRouter catalog id (contains /) silently dispatches the next chat request to that named profile's endpoint using the named profile's API key — not to OpenRouter. The picker status line claims the switch reached OpenRouter (via OpenRouter (api key) / https://openrouter.ai/api/v1), but the actual HTTP request hits the named profile's base_url with its api_key_env.

This is the routing counterpart of #444 and the live-catalog issue tracked in github-issue-inactive-named-provider-live-catalog.md (the second-open draft). The picker knows the model exists on OpenRouter; the route-resolution layer decides it doesn't matter and uses whatever happens to be sitting in the OpenRouter slot, which at startup is the user's named profile runtime.

Concrete reproduction (kilocode)

Config:

[provider]
default_provider = "kilocode"
default_model    = "minimax/minimax-m3:free"

[providers.kilocode]
type        = "open-ai-compatible"
base_url    = "https://api.kilo.ai/api/gateway"
auth        = "bearer"
api_key_env = "KILO_API_KEY"
# model_catalog = false  (default)

Sequence:

  1. Launch jcode. Active provider is Kilo; OpenRouter slot is filled with Kilo's openai-compatible:kilocode runtime (see ConfigProviderSelection::NamedProfile(_).active_provider() == ActiveProvider::OpenRouter).

  2. Type /model z-ai/glm-5.2:free. Bare id, contains /, not in any built-in catalog.

  3. Picker status line / model-route display:

    Model → z-ai/glm-5.2:free via OpenRouter (api key)
    https://openrouter.ai/api/v1
    

    route.detail is the OpenRouter api base, taken from the catalog model route for z-ai/glm-5.2:free (which simplified_model_routes_for_picker / multiprovider_model_routes builds with api_method = "openrouter"). The Switched to model: z-ai/glm-5.2:free system message is shown.

  4. Send a prompt. The actual HTTP request goes to:

    OpenAI-compatible chat request failed
      endpoint: https://api.kilo.ai/api/gateway/chat/completions
      model:    z-ai/glm-5.2:free
      auth:     KILO_API_KEY
      status:   404 Not Found
      response: {"error":"The requested model is currently unavailable…","error_type":"unavailable_model", …}
    

    — wrong endpoint, wrong auth, wrong model namespace. Kilo doesn't carry that model id at all.

  5. Failover kicks in (the bare z-ai/glm-5.2:free is OpenRouter-shaped, so a [jcode-provider-failover] prompt is built). The offered fallback message says "switch to z-ai/glm-5.2:free via openrouter (auto)" — i.e. the system knows the model is on OpenRouter and would route correctly after the user accepts.

Root cause

In crates/jcode-base/src/provider/mod.rs::set_model:

// Detect which provider an unprefixed model belongs to.
let target_provider = provider_for_model(model);
if let Some(target_provider) = target_provider
    && let Some(target) = provider_from_model_key(target_provider)
{
    self.set_model_on_provider(target, model)
}

provider_for_model("z-ai/glm-5.2:free") (crates/jcode-base/src/provider/models.rs:1166) returns Some("openrouter") because the model contains /. So set_model_on_provider(OpenRouter, …) is called. The actual bug lives in that branch's "rebind" logic in set_model_on_provider_with_credential_modes (same file, ~L1080–L1133):

let needs_rebind = match self.openrouter_provider().as_deref() {
    None => true,
    Some(provider) => {
        !provider.supports_provider_routing_features()
            && provider
                .direct_openai_compatible_route_parts()
                .and_then(|(_provider, api_method, _detail)| {
                    api_method
                        .strip_prefix("openai-compatible:")
                        .map(str::trim)
                        .and_then(crate::provider_catalog::openai_compatible_profile_by_id)
                })
                .map(|profile| {
                    profile.id != crate::provider_catalog::OPENAI_COMPAT_PROFILE.id
                })
                .unwrap_or(false)
    }
};

The check is meant to protect a legitimate custom OpenAI-compatible profile from being silently rebinded to the OpenRouter API-key runtime. But openai_compatible_profile_by_id only looks up built-in catalog profiles (OPENAI_COMPAT_PROFILE etc.). A user-defined named profile like kilocode is never in that table, so the lookup returns Noneunwrap_or(false)needs_rebind = false. The slot is not rebound, and the call falls through to openrouter.set_model(model) — which here is Kilo's runtime — and set_active_provider(ActiveProvider::OpenRouter) is called for show. Subsequent complete() calls go to Kilo.

The comment block right above the check correctly identifies the design intent:

"But a custom OpenAI-compatible endpoint (generic profile or named config profile) owns the slot legitimately: its model IDs are provider-local and must not be re-routed through OpenRouter (or fail outright because no OPENROUTER_API_KEY is configured)."

…which is exactly the right policy when the user is staying on a custom endpoint. It is the wrong policy when the user typed a model id that the heuristic identifies as OpenRouter-shaped (contains('/')) while the slot happens to be occupied by a custom profile because that profile is the default but not necessarily the desired target for the next model switch.

Why the picker status lies

The picker / route catalog uses the static + live [[providers.kilocode.models]] config (crates/jcode-base/src/provider/catalog_routes.rs::append_openai_compatible_profile_routes + named_provider_profile_routes), but z-ai/glm-5.2:free is not in Kilo's static list, so the picker only sees it via the OpenRouter-built catalog (simplified_model_routes_for_picker returns api_method = "openrouter", detail = "simplified catalog"). The route display reads route.provider / route.detail from that catalog entry. By the time the request is dispatched, the orchestrator has flipped ActiveProvider::OpenRouter and reused whatever runtime was in the slot. Two surfaces, two truths.

Suggested fix directions

A. Distinguish "active profile" from "OpenRouter slot". The OpenRouter slot should always be the OpenRouter API-key runtime. The currently active custom profile should be a separate handle selected by default_provider. Switching to an OpenRouter-shaped bare id (or anything provider_for_model identifies as openrouter) should rebind the slot regardless of what was previously in it.

B. Make needs_rebind actually consult named profiles. Today openai_compatible_profile_by_id only resolves built-ins. Extend it (or the check) so that a named profile whose base_url is not the OpenRouter base still triggers a rebind when the dispatched model id looks OpenRouter-shaped. Equivalently: if provider_for_model(model) == Some("openrouter") and the slot is occupied by anything other than the real OpenRouter runtime, force rebind.

C. If the slot must stay a named profile when default is named, then at minimum refuse the switch with a clear error rather than silently dispatching through the wrong endpoint. The current behaviour is worse than failing — it claims success and silently sends the prompt to the wrong host with the wrong credentials.

D. Sanity-check at request time. Before complete(), compare the resolved route's api_method / api_base against the route the user-facing picker claim was built from. If they disagree, surface that in the error block (the OpenAI-compatible chat request failed\n endpoint: … already includes the endpoint, so a header line like route-source: model-route-picker vs route-source: slot-residue would help users diagnose the lie).

Reproduction steps

  1. Drop in ~/.jcode/config.toml:
    [provider]
    default_provider = "kilocode"
    
    [providers.kilocode]
    type        = "open-ai-compatible"
    base_url    = "https://api.kilo.ai/api/gateway"
    auth        = "bearer"
    api_key_env = "KILO_API_KEY"
    
    [[providers.kilocode.models]]
    id = "minimax/minimax-m3:free"
  2. jcode (or restart). Confirm active provider is Kilo via /usage / info widget.
  3. /model z-ai/glm-5.2:free.
  4. Observe "✓ Switched to model: z-ai/glm-5.2:free" and the OpenRouter-looking status line.
  5. Send any prompt.
  6. Observe the request actually hitting https://api.kilo.ai/api/gateway/chat/completions with KILO_API_KEY, getting back 404 / unavailable_model.

Related issues

Environment

  • jcode v0.81.4 (build 896f866), Linux x86_64.
  • default_provider = "kilocode" (named profile in ~/.jcode/config.toml).
  • Triggered by /model z-ai/glm-5.2:free (bare id with /, matches OpenRouter heuristic).
  • OpenRouter API key is configured (https://openrouter.ai/api/v1 is reachable and serves the model — confirmed by the Ctrl+Y "switch to openrouter (auto)" fallback message jcode offers after the failure).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    autonomous: noNeeds your brain: a product/design decision is required before anyone acts.bugSomething isn't workingpriority: highP1 - important bug or impactful feature, fix soontriage: needs-decisionNeeds maintainer decision/design thoughttriage: reproducibleClear repro + clear fix path

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions