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);