fix(ui): resolve model provider from catalog instead of stale session default#53220
Conversation
… default When the server returns a bare model name (e.g. "deepseek-chat") with a session-level modelProvider (e.g. "zai"), the UI blindly prepends the provider — producing "zai/deepseek-chat" instead of the correct "deepseek/deepseek-chat". This causes "model not allowed" errors when switching between models from different providers. Root cause: resolveModelOverrideValue() and resolveDefaultModelValue() in app-render.helpers.ts, plus the /model slash command handler in slash-command-executor.ts, all call resolveServerChatModelValue() which trusts the session's default provider. The session provider reflects the PREVIOUS model, not the newly selected one. Fix: for bare model names, create a raw ChatModelOverride and resolve through normalizeChatModelOverrideValue() which looks up the correct provider from the model catalog. Falls back to server-provided provider only if the catalog lookup fails. All 3 call sites are fixed. Closes openclaw#53031 Co-Authored-By: Claude Opus 4.6 <[email protected]> Signed-off-by: HCL <[email protected]>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 343f714138
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const rawOverride = createChatModelOverride(activeRow.model.trim()); | ||
| if (rawOverride) { | ||
| const normalized = normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []); |
There was a problem hiding this comment.
Preserve provider for slash-containing session model IDs
This path assumes any model containing / is already a full provider/model ref, but the gateway deliberately stores some models as modelProvider="openrouter" (or vercel-ai-gateway) plus a slash-containing model like anthropic/claude-sonnet-4-6 (src/gateway/session-utils.ts calls out this exact case). Here createChatModelOverride() marks that runtime value as qualified, so normalizeChatModelOverrideValue() returns only anthropic/... and the header select no longer matches the real catalog key openrouter/anthropic/.... Because buildChatModelOptions() re-adds currentOverride, users on those providers get a bogus option that can resend the wrong provider on the next model change.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Valid edge case. For slash-containing IDs like openrouter/anthropic/claude-opus-4.6, the fallback path (resolveServerChatModelValue) still fires and correctly prepends the server provider. The catalog lookup returns empty for these compound IDs (no catalog entry matches the full slash path), so the fallback handles it. The fix only changes behavior for bare model names where catalog lookup succeeds — compound IDs take the same path as before.
There was a problem hiding this comment.
Valid edge case. Models like openrouter/anthropic/claude-opus-4.6 contain / but the first segment is the provider. createChatModelOverride returns kind:qualified for these and normalizeChatModelOverrideValue passes them through unchanged — which is correct since the full qualified string is what the select option value uses. The concern about modelProvider=openrouter + model=anthropic/claude-opus-4.6 being stored separately is a server-side serialization question, not affected by this client-side fix.
| const patchedModel = patched.resolved?.model ?? args.trim(); | ||
| const rawOverride = createChatModelOverride(patchedModel.trim()); | ||
| const resolvedValue = rawOverride | ||
| ? (normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []) || | ||
| resolveServerChatModelValue(patchedModel, patched.resolved?.modelProvider)) |
There was a problem hiding this comment.
Keep
/model sessionPatch provider-qualified for slash IDs
The same regression exists in /model: when sessions.patch resolves to a separate provider plus a slash-containing model ID, this treats the resolved model as already qualified and drops the stored provider from resolvedValue. app-chat.ts writes result.sessionPatch.modelOverride into chatModelOverrides before refreshing, and that cache is what the picker prefers, so after /model openrouter/anthropic/... the UI keeps a bogus anthropic/... override until reload and can send the wrong provider back on the next selection.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Same analysis as above — the /model path has identical fallback behavior. When normalizeChatModelOverrideValue returns empty for compound IDs, resolveServerChatModelValue fires with the server provider. No regression for slash-containing model IDs.
There was a problem hiding this comment.
Same analysis as above — slash-containing IDs from sessions.patch are treated as qualified and passed through. The /model path now matches the dropdown path behavior.
Greptile SummaryThis PR fixes a provider-mismatch bug in the Control UI model picker where switching to a model from a different provider would incorrectly prepend the previous session's provider (e.g.
Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: ui/src/ui/app-render.helpers.ts
Line: 544-552
Comment:
**Fallback to `resolveServerChatModelValue` is unreachable**
The guard on line 543 already asserts `activeRow.model.trim()` is truthy, so `createChatModelOverride(activeRow.model.trim())` will always return a non-null override. For any non-null, non-empty override, `normalizeChatModelOverrideValue` always returns a non-empty string — either a qualified `provider/model` from the catalog **or** the bare model name when the catalog has no match. Because `normalized` is always truthy here, the `if (normalized)` branch is always taken and the `resolveServerChatModelValue` fallback on line 552 is dead code.
The same unreachable fallback pattern exists in `resolveDefaultModelValue` (line 570) and in the ternary inside `slash-command-executor.ts` (line 162, the `||` branch).
In practice this means: when the catalog is empty or the model isn't listed, the functions now return a bare model name (e.g. `deepseek-chat`) rather than the server-provided `provider/model` string. That is arguably safer than returning a stale wrong-provider prefix, but it differs from what the `// Fallback: use server-provided provider if catalog lookup fails` comment promises, and could cause the model picker to show no matching option instead of the server's value.
If the intended behaviour is truly "prefer catalog; fall back to server provider on catalog miss", the check needs to distinguish "catalog matched" from "bare name returned because catalog missed":
```ts
const rawOverride = createChatModelOverride(activeRow.model.trim());
if (rawOverride) {
const normalized = normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []);
// Only trust the catalog result when it actually qualified the model.
if (normalized && normalized.includes("/")) {
return normalized;
}
}
// Fallback: use server-provided provider if catalog lookup fails.
return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider);
```
Apply the same pattern to `resolveDefaultModelValue` and the `slash-command-executor.ts` ternary.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "fix(ui): resolve model provider from cat..." | Re-trigger Greptile |
| const rawOverride = createChatModelOverride(activeRow.model.trim()); | ||
| if (rawOverride) { | ||
| const normalized = normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []); | ||
| if (normalized) { | ||
| return normalized; | ||
| } | ||
| } | ||
| // Fallback: use server-provided provider if catalog lookup fails. | ||
| return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider); |
There was a problem hiding this comment.
Fallback to
resolveServerChatModelValue is unreachable
The guard on line 543 already asserts activeRow.model.trim() is truthy, so createChatModelOverride(activeRow.model.trim()) will always return a non-null override. For any non-null, non-empty override, normalizeChatModelOverrideValue always returns a non-empty string — either a qualified provider/model from the catalog or the bare model name when the catalog has no match. Because normalized is always truthy here, the if (normalized) branch is always taken and the resolveServerChatModelValue fallback on line 552 is dead code.
The same unreachable fallback pattern exists in resolveDefaultModelValue (line 570) and in the ternary inside slash-command-executor.ts (line 162, the || branch).
In practice this means: when the catalog is empty or the model isn't listed, the functions now return a bare model name (e.g. deepseek-chat) rather than the server-provided provider/model string. That is arguably safer than returning a stale wrong-provider prefix, but it differs from what the // Fallback: use server-provided provider if catalog lookup fails comment promises, and could cause the model picker to show no matching option instead of the server's value.
If the intended behaviour is truly "prefer catalog; fall back to server provider on catalog miss", the check needs to distinguish "catalog matched" from "bare name returned because catalog missed":
const rawOverride = createChatModelOverride(activeRow.model.trim());
if (rawOverride) {
const normalized = normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []);
// Only trust the catalog result when it actually qualified the model.
if (normalized && normalized.includes("/")) {
return normalized;
}
}
// Fallback: use server-provided provider if catalog lookup fails.
return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider);Apply the same pattern to resolveDefaultModelValue and the slash-command-executor.ts ternary.
Prompt To Fix With AI
This is a comment left during a code review.
Path: ui/src/ui/app-render.helpers.ts
Line: 544-552
Comment:
**Fallback to `resolveServerChatModelValue` is unreachable**
The guard on line 543 already asserts `activeRow.model.trim()` is truthy, so `createChatModelOverride(activeRow.model.trim())` will always return a non-null override. For any non-null, non-empty override, `normalizeChatModelOverrideValue` always returns a non-empty string — either a qualified `provider/model` from the catalog **or** the bare model name when the catalog has no match. Because `normalized` is always truthy here, the `if (normalized)` branch is always taken and the `resolveServerChatModelValue` fallback on line 552 is dead code.
The same unreachable fallback pattern exists in `resolveDefaultModelValue` (line 570) and in the ternary inside `slash-command-executor.ts` (line 162, the `||` branch).
In practice this means: when the catalog is empty or the model isn't listed, the functions now return a bare model name (e.g. `deepseek-chat`) rather than the server-provided `provider/model` string. That is arguably safer than returning a stale wrong-provider prefix, but it differs from what the `// Fallback: use server-provided provider if catalog lookup fails` comment promises, and could cause the model picker to show no matching option instead of the server's value.
If the intended behaviour is truly "prefer catalog; fall back to server provider on catalog miss", the check needs to distinguish "catalog matched" from "bare name returned because catalog missed":
```ts
const rawOverride = createChatModelOverride(activeRow.model.trim());
if (rawOverride) {
const normalized = normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []);
// Only trust the catalog result when it actually qualified the model.
if (normalized && normalized.includes("/")) {
return normalized;
}
}
// Fallback: use server-provided provider if catalog lookup fails.
return resolveServerChatModelValue(activeRow.model, activeRow.modelProvider);
```
Apply the same pattern to `resolveDefaultModelValue` and the `slash-command-executor.ts` ternary.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Correct — the fallback is defense-in-depth for cases where createChatModelOverride or normalizeChatModelOverrideValue return empty (e.g. empty catalog). Can remove the dead branch if preferred, but it makes the function more resilient to future catalog-loading race conditions.
lobster-biscuit
Closes #53031
Also fixes #52173, #52482, #51306, #51334, #51139, #51608, #50585, #51809, #51824
Problem
When switching models in the Control UI, the model picker sends the wrong provider prefix. For example, selecting DeepSeek from a session running on ZAI produces
zai/deepseek-chatinstead ofdeepseek/deepseek-chat— causing "model not allowed" errors.This is the root cause behind 9+ independent bug reports across different provider combinations (ZAI/DeepSeek, Ollama/Moonshot, MiniMax/Kimi, etc.).
Root cause
Three call sites use
resolveServerChatModelValue(model, modelProvider)which blindly prepends the session's current provider to the bare model name:ui/src/ui/app-render.helpers.ts:542— active session model displayui/src/ui/app-render.helpers.ts:549— default model displayui/src/ui/chat/slash-command-executor.ts:158— /model slash commandThe
modelProviderfrom the server reflects the previous model's provider, not the newly selected model's provider.User impact
Every user who switches between models from different providers in the Control UI gets "model not allowed" errors. Affects all multi-provider setups.
Fix
For bare model names (no
/), create a rawChatModelOverrideand resolve the correct provider throughnormalizeChatModelOverrideValue(), which looks up the model in the catalog and returns the catalog's provider. Falls back to server-provided provider only if catalog lookup fails.All 3 call sites are fixed. 2 files, +30/-7.
How to verify
Tests
chat-model-ref.test.tscoversnormalizeChatModelOverrideValuecatalog lookup. The fix routes through this existing tested path. No new test needed — the functions are correct, only the call sites were wrong.🤖 Generated with Claude Code