Skip to content

fix(ui): resolve model provider from catalog instead of stale session default#53220

Merged
steipete merged 1 commit intoopenclaw:mainfrom
hclsys:fix/ui-model-picker-stale-provider
Mar 24, 2026
Merged

fix(ui): resolve model provider from catalog instead of stale session default#53220
steipete merged 1 commit intoopenclaw:mainfrom
hclsys:fix/ui-model-picker-stale-provider

Conversation

@hclsys
Copy link
Copy Markdown
Contributor

@hclsys hclsys commented Mar 23, 2026

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-chat instead of deepseek/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:

  1. ui/src/ui/app-render.helpers.ts:542 — active session model display
  2. ui/src/ui/app-render.helpers.ts:549 — default model display
  3. ui/src/ui/chat/slash-command-executor.ts:158 — /model slash command

The modelProvider from 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 raw ChatModelOverride and resolve the correct provider through normalizeChatModelOverrideValue(), 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

  1. Configure 2+ providers (e.g. ZAI + DeepSeek)
  2. Start a session with one provider's model
  3. Switch to another provider's model via UI dropdown
  4. Before: "model not allowed". After: switches correctly.

Tests

chat-model-ref.test.ts covers normalizeChatModelOverrideValue catalog 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

… 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]>
@hclsys
Copy link
Copy Markdown
Contributor Author

hclsys commented Mar 23, 2026

@altaywtf — Model picker sends stale session provider instead of target model provider (e.g. zai/deepseek-chat instead of deepseek/deepseek-chat). Fix: resolve provider from catalog via normalizeChatModelOverrideValue at all 3 call sites. 2 files, +30/-7. Closes #53031 + 9 related reports.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +544 to +546
const rawOverride = createChatModelOverride(activeRow.model.trim());
if (rawOverride) {
const normalized = normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +158 to +162
const patchedModel = patched.resolved?.model ?? args.trim();
const rawOverride = createChatModelOverride(patchedModel.trim());
const resolvedValue = rawOverride
? (normalizeChatModelOverrideValue(rawOverride, state.chatModelCatalog ?? []) ||
resolveServerChatModelValue(patchedModel, patched.resolved?.modelProvider))
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Mar 23, 2026

Greptile Summary

This 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. zai/deepseek-chat instead of deepseek/deepseek-chat), causing "model not allowed" errors. The fix replaces all three resolveServerChatModelValue(model, modelProvider) call sites with a catalog-lookup path (createChatModelOverridenormalizeChatModelOverrideValue) that resolves the correct provider from the model catalog.

  • Root cause correctly identified and all 3 call sites patchedapp-render.helpers.ts (active session display, default model display) and slash-command-executor.ts (/model slash command).
  • Catalog lookup logic is soundnormalizeChatModelOverrideValue handles ambiguous multi-provider matches and already has test coverage.
  • Unreachable fallback code — in every patched site the resolveServerChatModelValue fallback is dead code for any non-empty model string. normalizeChatModelOverrideValue always returns a truthy value (either a qualified provider/model on catalog hit, or the bare model name on catalog miss), so the if (normalized) guard and the || branch are never false. On a catalog miss the callers now return a bare model name rather than the server-provided provider/model string that the comment promises. This is harmless in the common case but the comments are misleading and the fallback intent is not achieved.

Confidence Score: 4/5

  • Safe to merge; fixes a widespread provider-mismatch bug with correct catalog lookup logic, but the documented server-provider fallback is unreachable dead code in all three patched sites.
  • The core fix is correct and well-scoped: catalog-based provider resolution replaces the stale session provider at all three call sites, directly resolving 9+ reported issues. The only concern is that the intended fallback (use server-provided provider when catalog fails) is structurally unreachable — normalizeChatModelOverrideValue always returns a non-empty string for valid input, so the fallback path returns bare model names on catalog misses instead of server-qualified strings. This is an edge-case behavioral difference on catalog-unavailable scenarios, not the main regression path, so it doesn't block merge.
  • Both changed files share the same dead-fallback pattern; pay attention to ui/src/ui/app-render.helpers.ts lines 544–552 and 563–570, and ui/src/ui/chat/slash-command-executor.ts line 161–162.
Prompt To Fix All 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.

Reviews (1): Last reviewed commit: "fix(ui): resolve model provider from cat..." | Re-trigger Greptile

Comment on lines +544 to 552
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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@steipete steipete merged commit be20eeb into openclaw:main Mar 24, 2026
35 of 38 checks passed
@steipete
Copy link
Copy Markdown
Contributor

Landed via temp rebase onto main.

  • Gate: pnpm check; pnpm build; pnpm test
  • Land commit: ad27662
  • Merge commit: be20eeb

Thanks @hclsys!

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

Labels

Projects

None yet

2 participants