From 38888e3d5bd7f6c364fffb5765d450bf021e055b Mon Sep 17 00:00:00 2001 From: ppvia Date: Fri, 21 Aug 2026 22:52:23 +0800 Subject: [PATCH] fix(claude): warm empty Desktop-3P alias registry on first /v1/messages after restart The registry that decodes hashed discovery ids (claude-opus-4-8-) is in-memory and only rebuilt by an anthropic-flavor GET /v1/models. A client that cached such an id from a previous process (e.g. a host app that pins the model after one discovery pass) replays it as the first request after a proxy restart; the alias cannot decode, the request misroutes via classifier affinity / raw passthrough, and the upstream rejects it with "Invalid model name passed in model=claude-opus-4-8-...." Warm the registry once via loopback discovery when a /v1/messages or /v1/messages/count_tokens request arrives while the registry is empty -- semantically identical to the client refreshing /v1/models first. Tested: bun test tests/claude-messages-endpoint.test.ts (43 pass; the new regression test fails without the src change), bun test tests/desktop-3p.test.ts tests/claude-models-discovery.test.ts tests/claude-inbound.test.ts (59 pass), bun run typecheck (clean). --- src/claude/desktop-3p.ts | 5 +++ src/server/index.ts | 19 ++++++++++ tests/claude-messages-endpoint.test.ts | 50 ++++++++++++++++++++++++++ 3 files changed, 74 insertions(+) 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 });