diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index f6906de7e0..8c3e37eea8 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -30,12 +30,13 @@ import { import { clearableDeadline, idleDeadline } from "../lib/abort"; import { estimateTokens } from "../lib/token-estimate"; import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router"; +import { registryEntryForProviderDestination } from "../providers/registry"; import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; import { readJsonRequestBody } from "./request-decompress"; import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; -import { conversationIdFromClaudeMetadata } from "./request-log-conversation"; +import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { @@ -786,8 +787,12 @@ async function handleClaudeMessagesWithBudget( // bodies: it 400s on sampling params ("Unsupported parameter: max_output_tokens", // verified live 2026-07-11). Strip them for that route; routed providers keep them. let nativeRoute = false; + let opencodeGoRoute = false; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + // Match the fixed key-auth destination before per-model wire overrides, including + // renamed Go providers without treating custom or lookalike URLs as Go. + opencodeGoRoute = registryEntryForProviderDestination(route.provider)?.id === "opencode-go"; // Settle the wire once so the sampling decision below reads the effective // adapter rather than the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic"); @@ -851,15 +856,27 @@ async function handleClaudeMessagesWithBudget( headers.set("chatgpt-account-id", token.chatgptAccountId); } } - if (nativeRoute) { + if (opencodeGoRoute) { + const session = req.headers.get("x-opencode-session"); + if (session) headers.set("x-opencode-session", session); + } + const hasExplicitGoSession = opencodeGoRoute + && (sessionLaneIdFromRequest(headers) !== undefined + || normalizeLogConversationId(headers.get("x-opencode-session")) !== undefined); + const synthesizeGoSession = opencodeGoRoute && !hasExplicitGoSession + && isRec(anthropicBody) + && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined; + // Go can also use the Responses adapter; its eligibility gate must win on both wires. + if (opencodeGoRoute ? synthesizeGoSession : nativeRoute) { // ChatGPT-backend prompt-cache affinity rides the session_id HEADER (codex // clients always send their session uuid; devlog 090 follow-up: body-level // prompt_cache_key alone still yielded cached_tokens:0). Claude Code never sends - // the header, so synthesize a stable per-session uuid from the same cache key — + // the header, so synthesize a stable per-session uuid from the same cache key. + // Routed Go requests need this lane too for their x-opencode-session affinity — // but ONLY for a real per-session key (metadata.user_id). The system-hash fallback // key is shared across Desktop conversations, and a shared session_id's backend // semantics are unproven (audit 133 R2#3): body prompt_cache_key only there. - if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { + if (cacheKeySource === "metadata" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") { headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); } } diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index ab28c8475f..c176dc703e 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -4,6 +4,7 @@ import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-tran import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; const MUSE_MODEL = "muse-spark-1.3-contributor"; @@ -25,7 +26,14 @@ function codexHeaders(child = "child-thread-a"): Record { }; } -function upstreamResponse(url: string): Response { +function upstreamResponse(url: string, stream = false): Response { + if (stream && url.endsWith("/chat/completions")) { + return new Response([ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", content: "ok" } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } })}\n\n`, + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + } if (url.endsWith("/responses")) { return Response.json({ id: "resp_opencode_go_session", @@ -54,6 +62,8 @@ async function captureRequest(input: { child?: string; provider?: OcxProviderConfig; nativeChat?: boolean; + claude?: boolean; + metadataUserId?: string; headers?: Record; } = {}): Promise<{ url: string; headers: Headers }> { const providerName = input.providerName ?? "opencode-go"; @@ -62,13 +72,26 @@ async function captureRequest(input: { globalThis.fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => { const url = String(requestInput); requests.push({ url, headers: new Headers(init?.headers) }); - return upstreamResponse(url); + return upstreamResponse(url, input.claude); }) as typeof fetch; const config = { providers: { [providerName]: input.provider ?? opencodeGo() }, } as unknown as OcxConfig; - const response = input.nativeChat ? await handleChatCompletions( + const response = input.claude ? await handleClaudeMessages( + new Request("http://localhost/v1/messages", { + method: "POST", + headers: input.headers ?? { "content-type": "application/json" }, + body: JSON.stringify({ + model: `${providerName}/${model}`, max_tokens: 64, stream: false, + system: "A shared system prompt is not a conversation identifier.", + messages: [{ role: "user", content: "ping" }], + ...(input.metadataUserId !== undefined ? { metadata: { user_id: input.metadataUserId } } : {}), + }), + }), + config, + { model: "", provider: "" }, + ) : input.nativeChat ? await handleChatCompletions( new Request("http://localhost/v1/chat/completions", { method: "POST", headers: input.headers ?? codexHeaders(input.child), @@ -97,6 +120,118 @@ describe("OpenCode Go session affinity (#3344)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); + test("Claude metadata gives stable Go affinity across turns and distinct conversations", async () => { + const input = { claude: true, model: CHAT_MODEL, metadataUserId: "user_test_account__session_conversation-a" }; + const first = await captureRequest(input); + const continued = await captureRequest(input); + const next = await captureRequest({ ...input, metadataUserId: "user_test_account__session_conversation-b" }); + expect(first.url).toBe("https://opencode.ai/zen/go/v1/chat/completions"); + // Fixed SHA-256 vectors calculated independently of the production helpers. + expect(first.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(continued.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(next.headers.get(SESSION_HEADER)).toBe("ocx_55fec02e7f2c7f9358958ab6d1589530"); + expect(first.headers.get(SESSION_HEADER)).not.toContain("conversation-a"); + }); + + test("Claude recognizes renamed canonical Go destinations and omits shared system affinity", async () => { + const input = { claude: true, model: CHAT_MODEL, providerName: "renamed-go" }; + const metadata = await captureRequest({ ...input, metadataUserId: "user_test_account__session_conversation-a" }); + const desktop = await captureRequest(input); + expect(metadata.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(desktop.headers.has(SESSION_HEADER)).toBe(false); + }); + + test("Claude explicit Go header precedes metadata and matches native Chat affinity", async () => { + const headers = { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }; + const claude = await captureRequest({ claude: true, model: CHAT_MODEL, headers, metadataUserId: "different-metadata-session" }); + const chat = await captureRequest({ nativeChat: true, model: CHAT_MODEL, headers }); + expect(claude.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + expect(chat.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + }); + + test("Claude affinity survives per-model Responses wire selection", async () => { + const input = { claude: true, metadataUserId: "user_test_account__session_conversation-a" }; + const chat = await captureRequest({ ...input, model: CHAT_MODEL }); + const responses = await captureRequest({ ...input, model: MUSE_MODEL }); + expect(responses.url).toBe("https://opencode.ai/zen/go/v1/responses"); + expect(chat.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + expect(responses.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + const explicit = await captureRequest({ + ...input, model: MUSE_MODEL, + headers: { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }, + }); + expect(explicit.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + }); + + for (const [model, url] of [ + [CHAT_MODEL, "https://opencode.ai/zen/go/v1/chat/completions"], + [MUSE_MODEL, "https://opencode.ai/zen/go/v1/responses"], + ] as const) { + test(`Claude ${model} falls back to valid metadata after invalid explicit Go identity`, async () => { + // Interior tab is constructible in HTTP Headers but rejected by the identity owner. + for (const session of ["", " ", "invalid\tidentity", "x".repeat(4097)]) { + const captured = await captureRequest({ + claude: true, model, metadataUserId: "user_test_account__session_conversation-a", + headers: { "content-type": "application/json", [SESSION_HEADER]: session }, + }); + expect(captured.url).toBe(url); + expect(captured.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + const invalidLane = await captureRequest({ + claude: true, model, metadataUserId: "user_test_account__session_conversation-a", + headers: { "content-type": "application/json", session_id: session }, + }); + expect(invalidLane.url).toBe(url); + expect(invalidLane.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + } + }); + + test(`Claude ${model} omits Go affinity without usable metadata identity`, async () => { + for (const metadataUserId of [undefined, "", " \t\n ", "invalid\u0000identity", "x".repeat(4097)]) { + const captured = await captureRequest({ claude: true, model, metadataUserId }); + expect(captured.url).toBe(url); + expect(captured.headers.has(SESSION_HEADER)).toBe(false); + expect(captured.headers.has("session_id")).toBe(false); + } + }); + + test(`Claude ${model} keeps explicit and operator identity with empty metadata`, async () => { + const input = { + claude: true, model, metadataUserId: "", + headers: { "content-type": "application/json", [SESSION_HEADER]: " client-session-a " }, + }; + const explicit = await captureRequest(input); + expect(explicit.url).toBe(url); + expect(explicit.headers.get(SESSION_HEADER)).toBe("ocx_516d593899f34b7baca2db37c7b0c8c5"); + const operator = await captureRequest({ ...input, provider: opencodeGo({ headers: { "X-OpenCode-Session": "operator-session" } }) }); + expect(operator.url).toBe(url); + expect(operator.headers.get(SESSION_HEADER)).toBe("operator-session"); + }); + + test(`Claude ${model} preserves explicit session lanes and operator header precedence`, async () => { + for (const laneHeader of ["session_id", "session-id", "thread-id", "x-codex-parent-thread-id"]) { + const headers = { "content-type": "application/json", [laneHeader]: "native-client-session", [SESSION_HEADER]: "different-fallback" }; + const input = { claude: true, model, headers, metadataUserId: "different-metadata-session" }; + const claude = await captureRequest(input); + expect(claude.url).toBe(url); + expect(claude.headers.get(SESSION_HEADER)).toBe("ocx_a197dbb87311c29a5fbe51140e3845ce"); + const operator = await captureRequest({ ...input, provider: opencodeGo({ headers: { "X-OpenCode-Session": "operator-session" } }) }); + expect(operator.url).toBe(url); + expect(operator.headers.get(SESSION_HEADER)).toBe("operator-session"); + } + }); + } + + test("Claude does not add Go affinity to custom or lookalike destinations", async () => { + for (const baseUrl of ["https://custom.example/v1", "https://opencode.ai.evil.test/zen/go/v1"]) { + const captured = await captureRequest({ + claude: true, model: CHAT_MODEL, providerName: "custom-go", + provider: opencodeGo({ baseUrl }), metadataUserId: "user_test_account__session_conversation-a", + headers: { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }, + }); + expect(captured.headers.has(SESSION_HEADER)).toBe(false); + } + }); + test("native Chat ingress preserves stable Go affinity and separates conversations", async () => { const provider = opencodeGo(); const input = { nativeChat: true, model: "omen-alpha", provider };