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:
-
Launch jcode. Active provider is Kilo; OpenRouter slot is filled with Kilo's openai-compatible:kilocode runtime (see ConfigProviderSelection::NamedProfile(_).active_provider() == ActiveProvider::OpenRouter).
-
Type /model z-ai/glm-5.2:free. Bare id, contains /, not in any built-in catalog.
-
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.
-
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.
-
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 None → unwrap_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
- 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"
jcode (or restart). Confirm active provider is Kilo via /usage / info widget.
/model z-ai/glm-5.2:free.
- Observe "✓ Switched to model: z-ai/glm-5.2:free" and the OpenRouter-looking status line.
- Send any prompt.
- 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).
Summary
When
default_provideris a user-defined named[providers.<name>]profile (any non-built-inopenai-compatibleprofile, 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'sbase_urlwith itsapi_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:
Sequence:
Launch jcode. Active provider is Kilo; OpenRouter slot is filled with Kilo's
openai-compatible:kilocoderuntime (seeConfigProviderSelection::NamedProfile(_).active_provider() == ActiveProvider::OpenRouter).Type
/model z-ai/glm-5.2:free. Bare id, contains/, not in any built-in catalog.Picker status line / model-route display:
route.detailis the OpenRouter api base, taken from the catalog model route forz-ai/glm-5.2:free(whichsimplified_model_routes_for_picker/multiprovider_model_routesbuilds withapi_method = "openrouter"). TheSwitched to model: z-ai/glm-5.2:freesystem message is shown.Send a prompt. The actual HTTP request goes to:
— wrong endpoint, wrong auth, wrong model namespace. Kilo doesn't carry that model id at all.
Failover kicks in (the bare
z-ai/glm-5.2:freeis OpenRouter-shaped, so a[jcode-provider-failover]prompt is built). The offered fallback message says "switch toz-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:provider_for_model("z-ai/glm-5.2:free")(crates/jcode-base/src/provider/models.rs:1166) returnsSome("openrouter")because the model contains/. Soset_model_on_provider(OpenRouter, …)is called. The actual bug lives in that branch's "rebind" logic inset_model_on_provider_with_credential_modes(same file, ~L1080–L1133):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_idonly looks up built-in catalog profiles (OPENAI_COMPAT_PROFILEetc.). A user-defined named profile likekilocodeis never in that table, so the lookup returnsNone→unwrap_or(false)→needs_rebind = false. The slot is not rebound, and the call falls through toopenrouter.set_model(model)— which here is Kilo's runtime — andset_active_provider(ActiveProvider::OpenRouter)is called for show. Subsequentcomplete()calls go to Kilo.The comment block right above the check correctly identifies the design intent:
…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), butz-ai/glm-5.2:freeis not in Kilo's static list, so the picker only sees it via the OpenRouter-built catalog (simplified_model_routes_for_pickerreturnsapi_method = "openrouter",detail = "simplified catalog"). The route display readsroute.provider/route.detailfrom that catalog entry. By the time the request is dispatched, the orchestrator has flippedActiveProvider::OpenRouterand 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 anythingprovider_for_modelidentifies asopenrouter) should rebind the slot regardless of what was previously in it.B. Make
needs_rebindactually consult named profiles. Todayopenai_compatible_profile_by_idonly resolves built-ins. Extend it (or the check) so that a named profile whosebase_urlis not the OpenRouter base still triggers a rebind when the dispatched model id looks OpenRouter-shaped. Equivalently: ifprovider_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'sapi_method/api_baseagainst the route the user-facing picker claim was built from. If they disagree, surface that in the error block (theOpenAI-compatible chat request failed\n endpoint: …already includes the endpoint, so a header line likeroute-source: model-route-pickervsroute-source: slot-residuewould help users diagnose the lie).Reproduction steps
~/.jcode/config.toml:jcode(or restart). Confirm active provider is Kilo via/usage/ info widget./model z-ai/glm-5.2:free.https://api.kilo.ai/api/gateway/chat/completionswithKILO_API_KEY, getting back404 / unavailable_model.Related issues
[[providers.<name>.models]]missing from the picker. This issue is the routing counterpart: model surface is correct, dispatch is wrong.openai-compatiblemodels incorrectly tagged withcopilot:prefix in model picker #694 (closed) — fixed misrouting a named-profile bare id to Copilot. This issue is the analogous misroute to OpenRouter.github-issue-inactive-named-provider-live-catalog.md(draft) — the live-catalog counterpart for inactive named profiles.Environment
default_provider = "kilocode"(named profile in~/.jcode/config.toml)./model z-ai/glm-5.2:free(bare id with/, matches OpenRouter heuristic).https://openrouter.ai/api/v1is reachable and serves the model — confirmed by the Ctrl+Y "switch to openrouter (auto)" fallback message jcode offers after the failure).