Skip to content

fix: switching models on an oai: endpoint works, and a session's replies name its real backend - #468

Merged
saucam merged 3 commits into
mainfrom
fix/endpoint-model-switch
Nov 13, 2026
Merged

saucam merged 3 commits into
mainfrom
fix/endpoint-model-switch

Conversation

@saucam

@saucam saucam commented Nov 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Switching models on an OpenAI-compatible endpoint (oai:…, e.g. rakuten-oss) now works. Before, every pick failed with Unknown model "…". Available: (nothing after "Available:"), and the session stayed on its default model.

Also, a session's replies now name the backend they come from, instead of always "(Claude)".

What was wrong

  • Two lists that disagreed. The model dropdown read an endpoint's catalog: its configured models, else the server's /models. The switch (#setModel) checked the pick against #currentModels, which knows only catalogs a backend reports. Endpoints report none, so for them that list is always empty and every pick was refused.
  • Claude ids got through. The obvious fix leaves a gap. resolveAgainstList passes any full claude-* id through whatever the list contains, so on an endpoint claude-sonnet-4-5 would have been accepted and sent to the gateway.
  • A hardcoded label. The agent label was `${name} (Claude)` for every session, whatever its backend. On an endpoint, that made "the model says it's Claude" look like codeoid's doing.

What changed

  • One #pickableModels(providerId) list serves both the dropdown and the switch, so they can't disagree again.
  • On an endpoint, a pick is resolved in this order:
    • A model the endpoint lists, by id or display name, is taken as is. A gateway may serve Claude models.
    • A Claude-shaped id it doesn't list is refused, with a clear reason. That covers Claude's ids, aliases like opus, and Bedrock's [region.]anthropic.claude-*. Example: "claude-sonnet-4-5" is a Claude model, not one this endpoint serves. Configured: conf-a, conf-b. Any other model id is passed to the server.
    • Any other id (the dropdown's "custom model id") is passed through, and the server judges it.
    • The id must follow the endpoint store's own rule: one line, at most 256 characters, no control characters. So nothing else reaches the gateway.
    • A removed endpoint takes no model. A backend switch while the list was being fetched refuses the pick instead of checking it against the old backend.
  • The fallback model goes through the same check.
  • The label names the backend: "(Codex)", "(Qwen Code)", "(Gemini CLI)", or the endpoint's display name exactly as you wrote it ("Rakuten OSS", "Rakuten (internal)"). It follows a backend switch. Claude sessions still read "(Claude)", and a ZeroID-registered identity keeps its own name.

Verification

  • End to end with a real daemon (codeoid start --local), the real CLI adding the endpoint (codeoid endpoint set … --model conf-a --model conf-b), and codeoid's own client library switching models the way the web UI does, against a fake OpenAI-compatible gateway that records which model each request asked for.

    Check main This PR
    Pick conf-b from the dropdown refused: Available: accepted
    Model the gateway was asked for conf-a (stuck on the default) conf-b
    Custom id served-a refused accepted, sent to the gateway
    claude-sonnet-4-5 accepted (sent to the gateway) refused, saying why
    Reply label e2e (Claude) e2e (Rakuten OSS)
  • New regression test in openai-endpoints.test.ts, against that file's fake server. It covers each case above and fails on main with the reported error. A unit test covers the backend label.

  • Audit: one round with three sandboxed reviewers (correctness, security, real behaviour). It found that the first version refused Claude-shaped models a gateway itself lists, plus the lows now covered above. All are fixed, with unit and daemon tests: a gateway's Claude model is accepted and sent; Bedrock ids are refused; control characters, including a header-injection attempt, are refused; a removed endpoint is refused.

  • Checks: typecheck and lint are clean. The full suite passes (6,166, 0 fail), all 23 e2e specs pass against a fresh web build, and the end-to-end run passes on the final code.

  • Known limit: on an endpoint with no configured models whose server is slow, a switch waits for its /models (up to the catalog's 5 s timeout), as the dropdown already does. Configuring the endpoint's models avoids the fetch.

Not changed

A gateway model (DeepSeek, GLM) answering "I'm Claude" does it on its own. Codeoid's prompt for endpoints names no model, and these turns show $? cost, which only non-Claude turns do. Naming the real model in the endpoint's system prompt would be a separate, optional change.


🤖 Implementation context (for agents / maintainers)
  • src/daemon/session-manager.ts
    • New #pickableModels(providerId): the endpoint catalog via #endpointCatalog.models, else #currentModels.
    • #modelsList uses it.
    • #setModel's resolve on an endpoint: resolveModelIdForProvider(id, providerId) === null refuses a Claude-shaped id; otherwise resolveAgainstList(id, models) ?? resolveModelIdForProvider(...).
    • Endpoint-specific error text.
  • src/daemon/session.ts: new exported backendLabel(provider) (the display name minus a trailing parenthetical, else the id). It's used for the default agent identity in the constructor and refreshed in the provider switch while the identity is the local agent:<id> one.
  • src/tests/openai-endpoints.test.ts: "a model the dropdown offers is one the switch accepts …", plus "a session's agent label".
  • #endpointCatalog.models never throws (an unreachable server gives []), so a pick still passes through when the server is down.

🤖 Generated with Claude Code

saucam and others added 2 commits November 13, 2026 10:21
…ies name its real backend

The model dropdown read an endpoint's catalog (configured models, else the
server's /models) but the switch (#setModel) checked the pick against
#currentModels, which is always empty for endpoints: they report no catalog
through onModels. Every pick failed with "Available: " and the session
stayed on its default. One #pickableModels list now serves both.

On an endpoint an id it isn't configured with (the dropdown's custom id) is
passed to the server; a Claude-shaped id is refused first, since
resolveAgainstList passes any claude-* id through, and the error says why
instead of an empty "Available:".

The agent label was hardcoded "<name> (Claude)": it now names the backend
("Codex", "Qwen Code", the endpoint's name) and follows a backend switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…aped; Bedrock ids, control characters, a removed endpoint and a mid-check backend switch are refused; an endpoint's label is its display name as written

- A gateway may serve Claude models: a model the endpoint lists (by id or
  display name) is taken as is; only a Claude-shaped id it doesn't list is
  refused, now including Bedrock's [region.]anthropic.claude-* ids.
- A model id follows the endpoint store's own rule (one line, at most 256
  characters, no control characters), so nothing else reaches the gateway.
- A removed endpoint takes no model; a backend switch while the list was
  fetched refuses the pick instead of checking it against the old backend.
- An endpoint's label is its display name as the user wrote it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saucam
saucam merged commit cb5903b into main Nov 13, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants