From ee9a86c0950763c9fe064abe22c6219e039c016f Mon Sep 17 00:00:00 2001 From: David Wang <72378768+david-wang-0@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:54:35 +0100 Subject: [PATCH] fix: derive Claude Go affinity from final combo destination Carry validated Claude affinity privately through combo replay and consume it only at the final canonical Go transport. Preserve explicit identity and operator precedence without leaking Go-only headers to other destinations. Addresses the late review on #3961. Adds deterministic random and failover regressions across both Go wires. Co-authored-by: GPT-6 Astra --- .../src/content/docs/guides/providers.md | 5 + src/server/claude-messages.ts | 28 ++--- src/server/responses/core.ts | 7 +- .../opencode-go-session-header.test.ts | 106 +++++++++++++++++- 4 files changed, 130 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 31bb2e3d42..82d1ccc040 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -393,6 +393,11 @@ Chat, bridged Chat, and Responses derive the same result. Explicit provider-conf session headers are operator overrides and are sent unchanged. Clients must keep the identifier stable within a conversation and distinct across conversations; requests without a session identifier cannot receive automatic session affinity. +For Claude Messages, valid conversation identity in `metadata.user_id` supplies +the fallback when no usable explicit session identifier exists. This fallback is +applied to the final Go destination, including random combo selections and fallback +attempts, rather than the preliminary route. Shared system-prompt cache keys do +not identify conversations, and Go-specific identity is not sent to non-Go targets. Generated Pi provider configurations enable `compat.sendSessionAffinityHeaders` so Pi sends its per-session identity to the proxy. Existing manually managed Pi configurations can set this option on their `opencodex` provider as well. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 8c3e37eea8..1db7e5835d 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -856,27 +856,26 @@ async function handleClaudeMessagesWithBudget( headers.set("chatgpt-account-id", token.chatgptAccountId); } } - 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 + // Carry Go identity out of band: a combo's preflight target may differ from its + // actual dispatch/fallback target. Never add Go-only identity to replay headers. + const metadataGoLane = cacheKeySource === "metadata" + && typeof internalBody.prompt_cache_key === "string" && 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) { + && conversationIdFromClaudeMetadata(isRec(anthropicBody.metadata) ? anthropicBody.metadata : undefined) !== undefined + ? normalizeLogConversationId(uuidFromHex(internalBody.prompt_cache_key)) + : undefined; + const claudeGoSessionLane = sessionLaneIdFromRequest(headers) + ?? normalizeLogConversationId(req.headers.get("x-opencode-session")) + ?? metadataGoLane; + if (nativeRoute && !opencodeGoRoute) { // 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. - // 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 + // Use ONLY 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" && (synthesizeGoSession || !headers.has("session_id")) && typeof internalBody.prompt_cache_key === "string") { + if (cacheKeySource === "metadata" && !headers.has("session_id") && typeof internalBody.prompt_cache_key === "string") { headers.set("session_id", uuidFromHex(internalBody.prompt_cache_key)); } } @@ -922,6 +921,7 @@ async function handleClaudeMessagesWithBudget( // Without this the replay would look native and a Responses-scoped wire default // would fire, disagreeing with the pre-flight decision above. inboundWire: "anthropic", + claudeGoAffinity: { sessionLane: claudeGoSessionLane }, stripClaudeMainAuthForNoncanonicalForward: true, translatorBudget, ...(logIds ? { onFirstOutput: () => recordFirstOutput(logCtx, logIds.start) } : {}), diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index b961e7cef9..bad909d821 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1652,6 +1652,8 @@ export interface ConsumedComboFailure { export interface HandleResponsesOptions { + /** Internal Claude replay identity; consumed only by the final canonical Go transport. */ + claudeGoAffinity?: { sessionLane?: string }; /** Original live policy owner; separate from caller-specific routing/sidecar snapshots. */ codexAuthPolicy?: CodexAuthPolicyConfig; turnAdmissionLease?: AdmissionLease; @@ -2312,6 +2314,7 @@ async function applyFinalRouteRequestNormalization(args: { logCtx: RequestLogContext; inboundWire: InboundWire; inboundTransport?: "websocket"; + claudeGoAffinity?: HandleResponsesOptions["claudeGoAffinity"]; }): Promise { const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args; const effortSelector = prepareEffortNormalization(parsed, route); @@ -2340,7 +2343,8 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). route.provider = resolveOpenCodeGoTransport(route.provider, - sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); + args.claudeGoAffinity ? args.claudeGoAffinity.sessionLane + : sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; @@ -3686,6 +3690,7 @@ async function handleResponsesInner( logCtx, inboundWire, inboundTransport: options.inboundTransport, + claudeGoAffinity: options.claudeGoAffinity, }); // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before // the normal post-resolution provider label is assigned. diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index c176dc703e..4171ef00a8 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { clearComboSelectionState, clearComboTargetCooldowns } from "../../src/combos"; import { providerConfigSeed } from "../../src/providers/derive"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { getProviderRegistryEntry } from "../../src/providers/registry"; @@ -120,6 +121,109 @@ describe("OpenCode Go session affinity (#3344)", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); + for (const model of [CHAT_MODEL, MUSE_MODEL]) { + for (const preliminaryAdapter of ["openai-chat", "openai-responses"] as const) { + for (const strategy of ["random", "failover"] as const) { + for (const identity of [ + { name: "metadata", headers: {}, metadata: "user_test_account__session_conversation-a", expected: "ocx_a89540229ef781fd5f7adf92a711b436" }, + { name: "explicit Go header", headers: { [SESSION_HEADER]: "client-session-a" }, metadata: "other-session", expected: "ocx_516d593899f34b7baca2db37c7b0c8c5" }, + { name: "explicit lane", headers: { session_id: "native-client-session", [SESSION_HEADER]: "client-session-a" }, metadata: "other-session", expected: "ocx_a197dbb87311c29a5fbe51140e3845ce" }, + { name: "operator override", headers: {}, metadata: "user_test_account__session_conversation-a", operator: true, expected: "operator-session" }, + { name: "invalid explicit lane", headers: { session_id: "invalid\tidentity", [SESSION_HEADER]: "invalid\tidentity" }, metadata: "user_test_account__session_conversation-a", expected: "ocx_a89540229ef781fd5f7adf92a711b436" }, + { name: "invalid metadata", headers: {}, metadata: "invalid\u0000identity", expected: null }, + { name: "shared system only", headers: {}, metadata: undefined, expected: null }, + ]) { + test(`Claude ${strategy} ${preliminaryAdapter} to Go uses ${identity.name} on ${model}`, async () => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + const requests: Array<{ url: string; headers: Headers }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + requests.push({ url, headers: new Headers(init?.headers) }); + if (url.startsWith("https://other.example")) { + return Response.json({ error: { message: "model retired", code: "model_not_found" } }, { status: 404 }); + } + return upstreamResponse(url, true); + }) as typeof fetch; + const config = { + providers: { + other: { adapter: preliminaryAdapter, authMode: "key", baseUrl: "https://other.example/v1", apiKey: "test-key", models: ["other"] }, + "renamed-go": opencodeGo(identity.operator ? { headers: { "X-OpenCode-Session": "operator-session" } } : {}), + }, + combos: { affinity: { strategy, targets: [ + { provider: "other", model: "other" }, { provider: "renamed-go", model }, + ] } }, + } as unknown as OcxConfig; + const entropy = spyOn(Math, "random").mockReturnValue(0.9); + // Preliminary route checks the first target; dispatch independently picks Go. + entropy.mockReturnValueOnce(0); + try { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json", ...identity.headers } as Record, + body: JSON.stringify({ model: "combo/affinity", max_tokens: 64, stream: false, + messages: [{ role: "user", content: "ping" }], + system: "Shared system prompt is not a session.", + metadata: { user_id: identity.metadata } }), + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(requests.at(-1)?.url).toStartWith("https://opencode.ai/zen/go/v1/"); + expect(requests.at(-1)?.headers.get(SESSION_HEADER)).toBe(identity.expected); + if (strategy === "failover") { + expect(requests).toHaveLength(2); + expect(requests[0]?.headers.has(SESSION_HEADER)).toBe(false); + } else { + expect(requests).toHaveLength(1); + } + } finally { + entropy.mockRestore(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + } + }); + } + } + } + + test(`Claude random Go preflight does not leak affinity to a final non-Go Responses target (${model})`, async () => { + clearComboSelectionState(); + clearComboTargetCooldowns(); + const requests: Headers[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://other.example/v1/responses"); + requests.push(new Headers(init?.headers)); + return upstreamResponse(String(input)); + }) as typeof fetch; + const config = { + providers: { + other: { adapter: "openai-responses", authMode: "key", baseUrl: "https://other.example/v1", apiKey: "test-key", models: ["other"] }, + "renamed-go": opencodeGo(), + }, + combos: { affinity: { strategy: "random", targets: [ + { provider: "renamed-go", model }, { provider: "other", model: "other" }, + ] } }, + } as unknown as OcxConfig; + const entropy = spyOn(Math, "random").mockReturnValue(0.9).mockReturnValueOnce(0); + try { + const response = await handleClaudeMessages(new Request("http://localhost/v1/messages", { + method: "POST", headers: { "content-type": "application/json", [SESSION_HEADER]: "client-session-a" }, + body: JSON.stringify({ model: "combo/affinity", max_tokens: 64, stream: false, + messages: [{ role: "user", content: "ping" }], + metadata: { user_id: "user_test_account__session_conversation-a" } }), + }), config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(requests).toHaveLength(1); + expect(requests[0]?.has(SESSION_HEADER)).toBe(false); + expect(requests[0]?.has("session_id")).toBe(false); + } finally { + entropy.mockRestore(); + clearComboSelectionState(); + clearComboTargetCooldowns(); + } + }); + } + 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);