diff --git a/src/claude/desktop-3p.ts b/src/claude/desktop-3p.ts index 5fce7b147e..bedc0ce5aa 100644 --- a/src/claude/desktop-3p.ts +++ b/src/claude/desktop-3p.ts @@ -312,6 +312,11 @@ export function generateDesktop3pModels( return models; } +/** True while no Desktop registry has been generated in this process (fresh boot). */ +export function desktop3pRegistryIsEmpty(): boolean { + return desktop3pRegistry.size === 0; +} + /** Resolve an alias using the most recently generated Desktop model registry. */ export function resolveDesktop3pAlias(alias: string): string | null { return desktop3pRegistry.get(alias) ?? null; diff --git a/src/server/index.ts b/src/server/index.ts index 70433d845c..519264d680 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1283,6 +1283,25 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server) is in-memory and only rebuilt by an anthropic-flavor + // GET /v1/models. A client that cached such an id from a previous process can + // replay it as the FIRST request after a restart; with an empty registry the + // alias cannot decode and the request misroutes (classifier affinity or raw + // passthrough → upstream "Invalid model name"). Warm the registry once via + // loopback discovery — semantically the same as the client refreshing models. + if ((url.pathname === "/v1/messages" || url.pathname === "/v1/messages/count_tokens") && req.method === "POST") { + const { desktop3pRegistryIsEmpty } = await import("../claude/desktop-3p"); + if (desktop3pRegistryIsEmpty()) { + const warmHeaders = new Headers({ "anthropic-version": "2023-06-01" }); + const warmAuth = req.headers.get("authorization"); + const warmKey = req.headers.get("x-api-key"); + if (warmAuth) warmHeaders.set("authorization", warmAuth); + if (warmKey) warmHeaders.set("x-api-key", warmKey); + try { await fetch(new URL("/v1/models", url.origin), { headers: warmHeaders }); } catch { /* fall through to existing resolution */ } + } + } + // Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path). // Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9). if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") { diff --git a/tests/claude-messages-endpoint.test.ts b/tests/claude-messages-endpoint.test.ts index 03cacabe82..110912555f 100644 --- a/tests/claude-messages-endpoint.test.ts +++ b/tests/claude-messages-endpoint.test.ts @@ -1571,3 +1571,53 @@ test("count_tokens is CJK-aware: Korean body counts more tokens than equal-lengt await server.stop(true); } }); + +test("first /v1/messages after a restart self-heals an empty Desktop-3P registry (cached hashed id)", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + const baseUrl = `${upstream.url.toString().replace(/\/$/, "")}/v1`; + saveConfig({ + port: 0, + defaultProvider: "mock", + providers: { + mock: { + adapter: "openai-chat", + baseUrl, + apiKey: "k", + allowPrivateNetwork: true, + liveModels: false, + models: ["test-model"], + }, + }, + } as OcxConfig); + const server = startServer(0); + try { + const { buildDesktop3pRegistry, desktop3pAlias } = await import("../src/claude/desktop-3p"); + // Post-restart state: no discovery GET has run in this process, so the + // in-memory registry is empty while the client replays the hashed id it + // cached from the previous process. + buildDesktop3pRegistry([], []); + const alias = desktop3pAlias("mock", "test-model"); + const response = await fetch(new URL("/v1/messages?beta=true", server.url), { + method: "POST", + headers: { + "content-type": "application/json", + "x-api-key": "placeholder", + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: alias, + max_tokens: 128, + stream: true, + messages: [{ role: "user", content: "hi" }], + }), + }); + expect(response.status).toBe(200); + const text = await response.text(); + expect(text).toContain("Hello"); + // The upstream must see the decoded route, not the raw hashed alias. + expect(captured[0]?.model).toBe("test-model"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}, { timeout: SERVER_BUDGET_MS });