Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 14 additions & 14 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Expand Down Expand Up @@ -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) } : {}),
Expand Down
7 changes: 6 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2312,6 +2314,7 @@ async function applyFinalRouteRequestNormalization(args: {
logCtx: RequestLogContext;
inboundWire: InboundWire;
inboundTransport?: "websocket";
claudeGoAffinity?: HandleResponsesOptions["claudeGoAffinity"];
}): Promise<void> {
const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args;
const effortSelector = prepareEffortNormalization(parsed, route);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
106 changes: 105 additions & 1 deletion tests/providers/opencode-go-session-header.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, string>,
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);
Expand Down
Loading