From 98de7001b79d725b3b0da6eab58778bebe82b851 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 13 Nov 2026 10:21:16 +0800 Subject: [PATCH 1/2] fix: switching models on an oai: endpoint works, and a session's replies 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 " (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 --- CHANGELOG.md | 5 +++ src/daemon/session-manager.ts | 64 +++++++++++++++++++----------- src/daemon/session.ts | 16 +++++++- src/tests/openai-endpoints.test.ts | 46 +++++++++++++++++++++ 4 files changed, 107 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cec0da61..96fa4548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -256,6 +256,11 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixed +- **Switching models on an OpenAI-compatible endpoint (`oai:…`) works.** + The model dropdown listed the endpoint's models, but the switch checked the pick against a list that is always empty for endpoints, so every pick failed with "Available: " and nothing after it, and the session stayed on its default model. Both now read the same list. A model id the endpoint isn't configured with (the dropdown's custom id) is passed to the server, which judges it; a Claude model id is refused there, saying why. +- **A session's replies name the backend they come from.** + Every session was labelled "(Claude)" whatever its backend; it now reads "(Codex)", "(Qwen Code)", the endpoint's name, and so on, and follows a backend switch. Claude sessions read as before. + - **A monorepo package session can't plant its package's Claude settings, and going back a turn never follows a working directory swapped for a link** (#448). The working directory and every directory between it and the repository's top can't be moved aside from inside the sandbox any more, nor can a repository inside an added directory or any directory holding a path the sandbox keeps read-only. Before, a sandboxed command could move `packages/app` aside and make it again around a planted `.claude/settings.json`, whose hook a later Claude start there would run on the host. The working directory is pinned by its identity too, and the daemon's own work in it (the snapshots at each turn, going back a turn, a fork from a turn and its setup, a client's file read, the project's MCP config, the end-of-turn git looks) checks it before and after and refuses, says and audits a change; before, a working directory swapped for a link to the home had the home snapshotted, and going back a turn wrote and deleted files there. Every path a sandbox binds is pinned and bound as it was, never resolved again at a launch: before, a directory another session swapped for a link (one this session holds in place, or keeps read-only) was bound where the link led at this session's next command, writable, the daemon's private dir with every session's launchers included. An agent's own added directory is let go once another session works in it. See [docs/sandbox.md](docs/sandbox.md#pinned-paths). diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 8e4d6c3e..9f6f6805 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -2466,28 +2466,33 @@ mcpHub: this.#mcpHub, return this.#persistedModelsCache.get(providerId) ?? null; } + /** + * The models a picker offers for a provider, and what a pick is checked + * against: one list, so the dropdown and the switch never disagree. An + * endpoint's are its configured models, else the server's own /models + * (briefly cached); it reports none through `onModels`, so its + * `#currentModels` would always be empty. + */ + async #pickableModels(providerId: string): Promise<{ models: ModelInfo[]; live: boolean }> { + const endpoint = endpointNameOf(providerId); + if (endpoint === null) return this.#currentModels(providerId); + const { models, live } = await this.#endpointCatalog.models(endpoint); + return { + models: models.map((m) => ({ + value: m.id, + displayName: m.displayName, + ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}), + })), + live, + }; + } + async #modelsList( msg: Extract, ): Promise { // No provider = the catalog of the backend a new session would land on. const provider = msg.provider ?? this.#providers.defaultId; - const endpoint = endpointNameOf(provider); - if (endpoint !== null) { - // Configured models, else the server's own /models (briefly cached). - const { models, live } = await this.#endpointCatalog.models(endpoint); - return { - type: "models.list.result", - requestId: msg.id, - models: models.map((m) => ({ - value: m.id, - displayName: m.displayName, - ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}), - })), - live, - provider, - }; - } - const { models, live } = this.#currentModels(provider); + const { models, live } = await this.#pickableModels(provider); return { type: "models.list.result", requestId: msg.id, models, live, provider }; } @@ -9886,21 +9891,34 @@ mcpHub: this.#mcpHub, // (`opus` → "Opus"), or a full claude-* id. An unknown value is rejected // here with the set of valid choices, so `/model o` gets actionable // feedback. - const { models } = this.#currentModels(session.providerId); - const resolvedModel = resolveAgainstList(msg.model, models); + const { models } = await this.#pickableModels(session.providerId); + // An endpoint's server is the validator for an id it isn't configured with + // (the picker's "custom model id"); a Claude-shaped id is still refused there. + const onEndpoint = endpointNameOf(session.providerId) !== null; + // resolveAgainstList passes any full claude-* id through (Claude's catalog lags its releases), so on an + // endpoint a Claude-shaped id is refused first. + const resolve = (id: string) => + onEndpoint + ? resolveModelIdForProvider(id, session.providerId) === null + ? null + : (resolveAgainstList(id, models) ?? resolveModelIdForProvider(id, session.providerId)) + : resolveAgainstList(id, models); + const resolvedModel = resolve(msg.model); if (!resolvedModel) { + const listed = models.map((m) => m.value).join(", "); return { type: "response.error", requestId: msg.id, - error: `Unknown model "${msg.model}". Available: ${models - .map((m) => m.value) - .join(", ")}`, + // On an endpoint only a Claude-shaped id is refused: say so, rather than an empty "Available:". + error: onEndpoint + ? `"${msg.model}" is a Claude model, not one this endpoint serves. ${listed ? `Configured: ${listed}. ` : ""}Any other model id is passed to the server.` + : `Unknown model "${msg.model}". Available: ${listed}`, code: "invalid_request", }; } let resolvedFallback = msg.fallbackModel; if (typeof msg.fallbackModel === "string") { - const rf = resolveAgainstList(msg.fallbackModel, models); + const rf = resolve(msg.fallbackModel); if (!rf) { return { type: "response.error", diff --git a/src/daemon/session.ts b/src/daemon/session.ts index f59fc670..b1d5ccdc 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -2080,7 +2080,7 @@ export class Session { // Default agent identity — upgraded to ZeroID identity in SessionStart hook if manager is available this.#agentIdentity = { sub: `agent:${this.id}`, - name: `${opts.name} (Claude)`, + name: `${opts.name} (${backendLabel(this.#provider)})`, type: "agent", }; @@ -2830,6 +2830,10 @@ export class Session { // Resolved for the incoming backend before it is built (it may start at once to take the history). await this.#sandbox?.refresh(requested); this.#provider = this.#createProvider(requested); + // The local identity names the backend its replies come from (a ZeroID identity has its own name). + if (this.#agentIdentity.sub === `agent:${this.id}`) { + this.#agentIdentity = { ...this.#agentIdentity, name: `${this.name} (${backendLabel(this.#provider)})` }; + } this.#warnIfBudgetUnenforced(); this.#effortRestartPending = false; // the new backend starts with the session's effort this.#appliedAccount = this.#accountState(requested); @@ -12574,3 +12578,13 @@ function isNativeRef(ref: unknown): ref is NativeTurnRef { const id = (v: unknown) => typeof v === "string" && /^[A-Za-z0-9-]{8,128}$/.test(v); return id(r.backingId) && id(r.ref) && (r.dir === undefined || (typeof r.dir === "string" && r.dir.startsWith("/"))); } + +/** + * A backend's short name for a session's agent label ("my-session (Codex)"): + * its display name without the vendor in parentheses ("Codex (OpenAI)" → + * "Codex"), an endpoint's own name, else its id. + */ +export function backendLabel(provider: { displayName?: string; id: string }): string { + const name = (provider.displayName ?? "").replace(/\s+\([^()]*\)\s*$/, "").trim(); + return name || provider.id; +} diff --git a/src/tests/openai-endpoints.test.ts b/src/tests/openai-endpoints.test.ts index 21367667..a74fcf96 100644 --- a/src/tests/openai-endpoints.test.ts +++ b/src/tests/openai-endpoints.test.ts @@ -13,6 +13,7 @@ import { draftConnection, EndpointError, EndpointStore, isPrivateHost } from ".. import { EndpointCatalog, syncEndpointProviders, watchEndpointProviders } from "../daemon/providers/endpoint/index.js"; import { ProviderRegistry } from "../daemon/providers/registry.js"; import { SessionManager } from "../daemon/session-manager.js"; +import { backendLabel } from "../daemon/session.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; import { ALL_SCOPES } from "../protocol/scopes.js"; @@ -675,4 +676,49 @@ describe("daemon", () => { const meta = JSON.parse(readFileSync(join(dir, "transcripts", `${created.data!.id}.meta.json`), "utf8")); expect(meta.providerId).toBe("oai:box"); }); + + test("a model the dropdown offers is one the switch accepts, and the server answers on it; a custom id passes through, a Claude one is refused", async () => { + await handle({ type: "endpoints.set", id: "s", name: "box", displayName: "GPU box", baseUrl: baseUrl(), apiKey: validKey, models: [{ id: "conf-a" }, { id: "conf-b" }] }); + const listed = (await handle({ type: "models.list", id: "m", provider: "oai:box" })) as unknown as { models: Array<{ value: string }> }; + expect(listed.models.map((m) => m.value)).toEqual(["conf-a", "conf-b"]); + const created = (await handle({ type: "session.create", id: "c", name: "sm", workdir: dir, providerId: "oai:box" })) as { data?: SessionInfo }; + const sessionId = created.data!.id; + await handle({ type: "session.attach", id: "a", sessionId }); + + // A model from the dropdown: accepted (it was refused with "Available: " and nothing after it), and used. + expect(await handle({ type: "session.set_model", id: "sm1", sessionId, model: "conf-b" })).toMatchObject({ type: "response.ok", data: { model: "conf-b" } }); + await handle({ type: "session.send", id: "m1", sessionId, text: "hi" }); + await waitFor(() => assistantTexts().length === 1); + expect(assistantTexts()[0]).toBe("hello from conf-b"); + + // The dropdown's custom model id: not configured, so the server is the judge. + expect(await handle({ type: "session.set_model", id: "sm2", sessionId, model: "served-a" })).toMatchObject({ type: "response.ok", data: { model: "served-a" } }); + await handle({ type: "session.send", id: "m2", sessionId, text: "again" }); + await waitFor(() => assistantTexts().length === 2); + expect(assistantTexts()[1]).toBe("hello from served-a"); + + // A Claude model isn't the endpoint's: refused, saying why and what is configured. + for (const model of ["claude-sonnet-4-5", "opus"]) { + const refused = await handle({ type: "session.set_model", id: `sm-${model}`, sessionId, model }); + expect(refused).toMatchObject({ type: "response.error", code: "invalid_request" }); + expect(String(refused.error)).toContain("is a Claude model"); + expect(String(refused.error)).toContain("Configured: conf-a, conf-b"); + } + + // Its replies are labelled with the endpoint, not "(Claude)". + const replies = pushed.flatMap((m) => (m.type === "session.message" && m.role === "assistant" ? [m.identity.name] : [])); + expect(replies.length).toBeGreaterThan(0); + for (const name of replies) expect(name).toBe("sm (GPU box)"); + }); +}); + +describe("a session's agent label", () => { + test("names its backend, without the vendor in parentheses: Claude sessions read as before", () => { + expect(backendLabel({ id: "claude", displayName: "Claude (Anthropic)" })).toBe("Claude"); + expect(backendLabel({ id: "codex", displayName: "Codex (OpenAI)" })).toBe("Codex"); + expect(backendLabel({ id: "qwen", displayName: "Qwen Code (Alibaba)" })).toBe("Qwen Code"); + expect(backendLabel({ id: "oai:box", displayName: "GPU box" })).toBe("GPU box"); + expect(backendLabel({ id: "oai:rakuten-oss", displayName: "rakuten-oss" })).toBe("rakuten-oss"); + expect(backendLabel({ id: "opencode", displayName: "" })).toBe("opencode"); + }); }); From 4f8dc102a3b2b5ecca7258344f6e2ad25c85432b Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Fri, 13 Nov 2026 11:08:49 +0800 Subject: [PATCH 2/2] fix: #468 audit: a model an endpoint lists is taken even if Claude-shaped; 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 --- src/daemon/session-manager.ts | 57 +++++++++++++++++++++--------- src/daemon/session.ts | 2 ++ src/tests/openai-endpoints.test.ts | 50 +++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 17 deletions(-) diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 9f6f6805..9b5bb56f 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -9891,28 +9891,34 @@ mcpHub: this.#mcpHub, // (`opus` → "Opus"), or a full claude-* id. An unknown value is rejected // here with the set of valid choices, so `/model o` gets actionable // feedback. - const { models } = await this.#pickableModels(session.providerId); - // An endpoint's server is the validator for an id it isn't configured with - // (the picker's "custom model id"); a Claude-shaped id is still refused there. - const onEndpoint = endpointNameOf(session.providerId) !== null; - // resolveAgainstList passes any full claude-* id through (Claude's catalog lags its releases), so on an - // endpoint a Claude-shaped id is refused first. - const resolve = (id: string) => - onEndpoint - ? resolveModelIdForProvider(id, session.providerId) === null - ? null - : (resolveAgainstList(id, models) ?? resolveModelIdForProvider(id, session.providerId)) - : resolveAgainstList(id, models); + const providerId = session.providerId; + const endpoint = endpointNameOf(providerId); + if (endpoint !== null && !this.#safeEndpoint(endpoint)) { + return { type: "response.error", requestId: msg.id, error: `The endpoint "${endpoint}" no longer exists: switch the session to another backend.`, code: "invalid_request" }; + } + const { models } = await this.#pickableModels(providerId); + // The backend changed while the list was fetched: a pick checked against the old one isn't the new one's. + if (session.providerId !== providerId) { + return { type: "response.error", requestId: msg.id, error: "The session's backend changed while the model was being checked: pick again.", code: "invalid_request" }; + } + // On an endpoint: a model it lists (by id or display name) is taken as is, Claude-shaped or not (a + // gateway may serve Claude models); a Claude-shaped id it doesn't list is refused; any other id is + // passed to its server, which judges it (the picker's custom model id). The id follows the endpoint + // store's own rule. Elsewhere the list decides. + const resolve = (id: string) => (endpoint !== null ? resolveOnEndpoint(id, models, providerId) : resolveAgainstList(id, models)); const resolvedModel = resolve(msg.model); if (!resolvedModel) { const listed = models.map((m) => m.value).join(", "); + const invalid = endpoint !== null && !isEndpointModelId(msg.model.trim()); return { type: "response.error", requestId: msg.id, - // On an endpoint only a Claude-shaped id is refused: say so, rather than an empty "Available:". - error: onEndpoint - ? `"${msg.model}" is a Claude model, not one this endpoint serves. ${listed ? `Configured: ${listed}. ` : ""}Any other model id is passed to the server.` - : `Unknown model "${msg.model}". Available: ${listed}`, + error: + endpoint === null + ? `Unknown model "${msg.model}". Available: ${listed}` + : invalid + ? `${JSON.stringify(msg.model)} isn't a model id: one line, at most 256 characters, no control characters.` + : `"${msg.model}" is a Claude model, not one this endpoint serves. ${listed ? `Configured: ${listed}. ` : ""}Any other model id is passed to the server.`, code: "invalid_request", }; } @@ -10941,3 +10947,22 @@ function referenceFolder(p: string): string { return abs; } } + +/** A model id an endpoint may be sent: the endpoint store's own rule (openai-endpoints.ts validateModels). */ +function isEndpointModelId(id: string): boolean { + return id.length > 0 && id.length <= 256 && !/[\x00-\x1f\x7f]/.test(id); +} + +/** Claude's ids in every form a gateway may name them: the catalog's, `claude-*`, Bedrock's `[region.]anthropic.claude-*`. */ +function isClaudeShaped(id: string, providerId: string): boolean { + return resolveModelIdForProvider(id, providerId) === null || /^(?:[a-z0-9-]+\.)*anthropic\.claude/i.test(id); +} + +/** How a model pick resolves on an endpoint (see SessionManager's set_model). */ +export function resolveOnEndpoint(raw: string, models: ReadonlyArray, providerId: string): string | null { + const id = raw.trim(); + if (!isEndpointModelId(id)) return null; + const listed = models.find((m) => m.value === id) ?? models.find((m) => m.displayName.toLowerCase() === id.toLowerCase()); + if (listed) return listed.value; + return isClaudeShaped(id, providerId) ? null : id; +} diff --git a/src/daemon/session.ts b/src/daemon/session.ts index b1d5ccdc..9a7ff8e4 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -12585,6 +12585,8 @@ function isNativeRef(ref: unknown): ref is NativeTurnRef { * "Codex"), an endpoint's own name, else its id. */ export function backendLabel(provider: { displayName?: string; id: string }): string { + // An endpoint's display name is the user's own words: used as is. + if (provider.id.startsWith("oai:")) return provider.displayName?.trim() || provider.id.slice("oai:".length); const name = (provider.displayName ?? "").replace(/\s+\([^()]*\)\s*$/, "").trim(); return name || provider.id; } diff --git a/src/tests/openai-endpoints.test.ts b/src/tests/openai-endpoints.test.ts index a74fcf96..2ac15c2b 100644 --- a/src/tests/openai-endpoints.test.ts +++ b/src/tests/openai-endpoints.test.ts @@ -12,7 +12,7 @@ import { join } from "node:path"; import { draftConnection, EndpointError, EndpointStore, isPrivateHost } from "../daemon/openai-endpoints.js"; import { EndpointCatalog, syncEndpointProviders, watchEndpointProviders } from "../daemon/providers/endpoint/index.js"; import { ProviderRegistry } from "../daemon/providers/registry.js"; -import { SessionManager } from "../daemon/session-manager.js"; +import { resolveOnEndpoint, SessionManager } from "../daemon/session-manager.js"; import { backendLabel } from "../daemon/session.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; @@ -710,6 +710,27 @@ describe("daemon", () => { expect(replies.length).toBeGreaterThan(0); for (const name of replies) expect(name).toBe("sm (GPU box)"); }); + + test("a Claude model a gateway lists is a model it serves; a removed endpoint takes no model (round-1 audit)", async () => { + await handle({ type: "endpoints.set", id: "s", name: "gw", baseUrl: baseUrl(), apiKey: validKey, models: [{ id: "claude-sonnet-4-5" }] }); + const created = (await handle({ type: "session.create", id: "c", name: "gw1", workdir: dir, providerId: "oai:gw" })) as { data?: SessionInfo }; + const sessionId = created.data!.id; + await handle({ type: "session.attach", id: "a", sessionId }); + expect(await handle({ type: "session.set_model", id: "m1", sessionId, model: "claude-sonnet-4-5" })).toMatchObject({ type: "response.ok", data: { model: "claude-sonnet-4-5" } }); + await handle({ type: "session.send", id: "t1", sessionId, text: "hi" }); + await waitFor(() => assistantTexts().length === 1); + expect(assistantTexts()[0]).toBe("hello from claude-sonnet-4-5"); + // Not listed: refused. + expect(await handle({ type: "session.set_model", id: "m2", sessionId, model: "us.anthropic.claude-3-5-sonnet-20241022-v2:0" })).toMatchObject({ type: "response.error" }); + // A control character never reaches the gateway. + const bad = await handle({ type: "session.set_model", id: "m3", sessionId, model: "x\r\nX-Injected: 1" }); + expect(bad).toMatchObject({ type: "response.error" }); + expect(String(bad.error)).toContain("isn't a model id"); + await handle({ type: "endpoints.remove", id: "r", name: "gw" }); + const gone = await handle({ type: "session.set_model", id: "m4", sessionId, model: "anything" }); + expect(gone).toMatchObject({ type: "response.error" }); + expect(String(gone.error)).toContain("no longer exists"); + }); }); describe("a session's agent label", () => { @@ -720,5 +741,32 @@ describe("a session's agent label", () => { expect(backendLabel({ id: "oai:box", displayName: "GPU box" })).toBe("GPU box"); expect(backendLabel({ id: "oai:rakuten-oss", displayName: "rakuten-oss" })).toBe("rakuten-oss"); expect(backendLabel({ id: "opencode", displayName: "" })).toBe("opencode"); + // An endpoint's display name is the user's own words: kept whole. + expect(backendLabel({ id: "oai:rakuten", displayName: "Rakuten (internal)" })).toBe("Rakuten (internal)"); + expect(backendLabel({ id: "oai:rakuten", displayName: "" })).toBe("rakuten"); + }); +}); + +describe("a model pick on an endpoint (round-1 audit of #468)", () => { + const listed = [ + { value: "conf-a", displayName: "Conf A" }, + { value: "claude-sonnet-4-5", displayName: "Claude via gateway" }, + ]; + test("a model the endpoint lists is taken, Claude-shaped or not, by id or display name", () => { + expect(resolveOnEndpoint("conf-a", listed, "oai:gw")).toBe("conf-a"); + expect(resolveOnEndpoint("conf a", listed, "oai:gw")).toBe("conf-a"); + expect(resolveOnEndpoint("claude-sonnet-4-5", listed, "oai:gw")).toBe("claude-sonnet-4-5"); + }); + test("a Claude model it doesn't list is refused, in Claude's, Bedrock's and alias forms; any other id passes", () => { + for (const id of ["claude-opus-4-1", "opus", "us.anthropic.claude-3-5-sonnet-20241022-v2:0", "anthropic.claude-3-haiku-20240307-v1:0"]) { + expect([id, resolveOnEndpoint(id, listed, "oai:gw")]).toEqual([id, null]); + } + expect(resolveOnEndpoint("org/model:tag", listed, "oai:gw")).toBe("org/model:tag"); + expect(resolveOnEndpoint(" deepseek-v4 ", [], "oai:gw")).toBe("deepseek-v4"); + }); + test("an id the endpoint store wouldn't accept is refused: empty, over 256 characters, control characters", () => { + for (const id of ["", " ", "x".repeat(257), "a\nb", "a\rb", "a\u0000b", "a\u007fb"]) { + expect([JSON.stringify(id), resolveOnEndpoint(id, [], "oai:gw")]).toEqual([JSON.stringify(id), null]); + } }); });