From 9e07834958ca0f62d909130fc68cc7f426e38752 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 1/3] 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 | 34 +++-- src/server/responses/core.ts | 7 +- .../opencode-go-session-header.test.ts | 124 +++++++++++++++++- 4 files changed, 154 insertions(+), 16 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 6b359a8ad5..f5c6b29704 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -475,6 +475,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 ba4d4999d3..1eaad8cffe 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -38,6 +38,7 @@ import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-dec import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; import { conversationIdFromClaudeMetadata, + getOrAllocateRequestSessionLane, linkRequestSessionLane, normalizeLogConversationId, sessionLaneIdFromRequest, @@ -867,27 +868,31 @@ async function handleClaudeMessagesWithBudget( }; } } - 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; + // Without any valid conversation identity, fall back to the request-scoped lane + // allocated on the admitted client request (#4172): stable across retries and + // route reconstruction, distinct per request, and never derived from a shared + // system-prompt cache key or from a later synthesized native session_id header. + const claudeGoSessionLane = sessionLaneIdFromRequest(headers) + ?? normalizeLogConversationId(req.headers.get("x-opencode-session")) + ?? metadataGoLane + ?? getOrAllocateRequestSessionLane(req); + 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)); } } @@ -934,6 +939,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, ...(trustedClaudeMainAuth ? { trustedClaudeMainAuth } : {}), // Claude's internal stored-main enrichment is not an original caller credential. diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1925ad7d91..786982575f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1673,6 +1673,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; @@ -2474,6 +2476,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); @@ -2501,7 +2504,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, getOrAllocateRequestSessionLane(req)); + route.provider = resolveOpenCodeGoTransport(route.provider, + args.claudeGoAffinity ? args.claudeGoAffinity.sessionLane : getOrAllocateRequestSessionLane(req)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; @@ -3907,6 +3911,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 9b326e294c..be63d32d9d 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"; @@ -122,6 +123,127 @@ 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: "isolated" }, + { name: "shared system only", headers: {}, metadata: undefined, expected: "isolated" }, + ]) { + test(`Claude ${strategy} ${preliminaryAdapter} to Go uses ${identity.name} on ${model}`, async () => { + // Without valid identity the final Go destination still receives a + // request-scoped lane (#4172): well-formed, never the shared-system or + // metadata-derived value, and distinct across independent requests. + const observed: string[] = []; + for (const round of [1, 2]) { + 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/"); + const lane = requests.at(-1)?.headers.get(SESSION_HEADER); + if (identity.expected === "isolated") { + expect(lane).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(lane).not.toBe("ocx_a89540229ef781fd5f7adf92a711b436"); + observed.push(lane!); + } else { + expect(lane).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(); + } + if (identity.expected !== "isolated" && round === 1) break; + } + if (identity.expected === "isolated") { + expect(observed).toHaveLength(2); + expect(observed[0]).not.toBe(observed[1]); + } + }); + } + } + } + + 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); From db9949fa7dc924644ab7051911b5231a5666abd0 Mon Sep 17 00:00:00 2001 From: David Wang <72378768+david-wang-0@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:50:21 +0100 Subject: [PATCH 2/3] docs: state full Claude Go affinity precedence; document handleClaudeMessagesWithBudget Spell out that configured OpenCode Go session headers stay authoritative and that explicit session or thread headers win over the metadata.user_id fallback, as requested in CodeRabbit's review of #4050. Add a JSDoc block to the one changed production function that lacked one. Co-authored-by: Claude Fable 5.1 --- docs-site/src/content/docs/guides/providers.md | 5 +++-- src/server/claude-messages.ts | 6 ++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index f5c6b29704..67905d69f3 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -475,8 +475,9 @@ 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 +For Claude Messages, configured OpenCode Go session headers remain authoritative. +Otherwise, valid explicit session or thread headers take precedence, and valid +conversation identity in `metadata.user_id` supplies the fallback. 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. diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 1eaad8cffe..40e0a3fbc6 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -638,6 +638,12 @@ export async function handleClaudeMessages( } } +/** + * Translate a Claude Messages request, route it through the Responses pipeline, + * and translate the reply back. Runs under a translator budget owned by the + * caller; Go session affinity is derived here and handed to the final Go + * transport out of band rather than through replay headers. + */ async function handleClaudeMessagesWithBudget( req: Request, config: OcxConfig, From e5c2411f7b35c6265aacce19f66f13eace544579 Mon Sep 17 00:00:00 2001 From: David Wang <72378768+david-wang-0@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:12:41 +0100 Subject: [PATCH 3/3] docs: say sessionless Go requests get an isolated per-request session The guide's sessionless sentence predated the request-scoped lane allocator that landed on dev (#4184 via #4226). A request without a session identifier still receives no inferred cross-request identity, but it is now sent under an isolated per-request session rather than none at all. Co-authored-by: Claude Fable 5.1 --- docs-site/src/content/docs/guides/providers.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 67905d69f3..111be5c328 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -473,8 +473,10 @@ inbound value is treated as client input and hashed into Go affinity; the internal bridge carries the original value, so native Chat, bridged Chat, and Responses derive the same result. Explicit provider-config 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. +identifier stable within a conversation and distinct across conversations. A request +without any session identifier is not given an inferred cross-request identity; it is +instead sent under a session allocated for that request alone, isolated from every +other request (see the provider reference for how that value is carried). For Claude Messages, configured OpenCode Go session headers remain authoritative. Otherwise, valid explicit session or thread headers take precedence, and valid conversation identity in `metadata.user_id` supplies the fallback. This fallback is