Skip to content
Closed
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
16 changes: 14 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -756,8 +756,20 @@ async function handle(
: subagentNamespace(
responsesIdentity?.value ?? conversationSignalResponses(parsed as ResponsesRequestBody, convHeader),
(parsed as ResponsesRequestBody).instructions,
);
const sessionId = deriveProxySessionId(req.headers, protocol, upstreamOrigin, conversation);
);
// #286: a client-provided native session id (codex session-id header,
// claude x-claude-code-session-id, body session_id, prompt_cache_key…)
// is the only invariant tied to a conversation — key the session on
// protocol+conversation only, so credential rotation (OAuth bearer)
// and relay/upstream switches don't orphan the compression state.
const identity: ConversationIdentity | undefined = protocol === "anthropic"
? convHeader
? { value: conversation, source: "header", clientProvided: true }
: { value: conversation, source: "content-fingerprint", clientProvided: false }
: protocol === "openai"
? openaiIdentity
: responsesIdentity;
const sessionId = deriveProxySessionId(req.headers, protocol, upstreamOrigin, conversation, identity);
// Two separate uses of the conversation signal:
// - `affinity`: header value forwarded upstream for sticky-routing /
// cache pools. Synthesized as ses_<conversation> when the client
Expand Down
46 changes: 36 additions & 10 deletions src/session-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,29 @@ export type ConversationIdentity = {
/**
* Session identity for the proxy's OWN compression state.
*
* A session is uniquely keyed by FOUR dimensions (all AND-ed):
* A session is keyed by protocol + conversation, where the conversation
* dimension is the client's own notion of "which conversation this is" —
* taken from a client-provided signal when available, falling back to a
* content fingerprint.
*
* When the conversation is a CLIENT-PROVIDED native session id (session
* header, body session_id, prompt_cache_key), the final id is
* hash(protocol | conversation) only (issue #286): the session id is the
* only invariant tied to a conversation, and the account credential
* (rotating OAuth bearer) or the upstream (relay switch) changing must not
* orphan the conversation's compression state.
*
* When the conversation is a CONTENT FINGERPRINT (no client signal), the id
* keeps FOUR dimensions (all AND-ed):
* 1. protocol — "anthropic" | "openai" | "responses" (different wire
* formats must never share compression state)
* 2. upstream — the resolved upstream origin, e.g. "https://open.bigmodel.cn"
* (same key hitting two providers must not share state)
* 3. apiKey — the account credential from Authorization / x-api-key
* (different accounts must never share state, even if they
* happen to send the same conversation content)
* 4. conversation — the client's own notion of "which conversation this is",
* taken from a client-provided signal when available, falling
* back to a content fingerprint.
* 4. conversation — the content fingerprint (a content hash with a real
* collision surface, hence the isolation dimensions).
*
* The result is a stable, opaque id used ONLY inside the proxy (to key the
* compression-state store). It is NEVER sent upstream — it embeds the key and
Expand Down Expand Up @@ -71,20 +83,34 @@ export function clientConversationHeader(headers: Record<string, string | string

/**
* Derive the proxy-internal session id. The conversation dimension MUST be
* supplied by the caller via `extra.conversation` (typically the output of
* the per-protocol conversationSignal* helper, which already falls back to a
* hash of the first user message). There is intentionally NO content-
* fingerprint fallback inside this function — a silent "" default would
* collapse every anonymous session onto one id. If `conversation` is missing
* the caller has a bug and we throw rather than silently mis-isolating.
* supplied by the caller (typically the output of the per-protocol
* conversationSignal* helper, which already falls back to a content hash).
* There is intentionally NO content-fingerprint fallback inside this
* function — a silent "" default would collapse every anonymous session onto
* one id. If `conversation` is missing the caller has a bug and we throw
* rather than silently mis-isolating.
*
* Identity strength (issue #286): when the caller passes a
* client-provided identity (native session header, body session_id,
* prompt_cache_key — `identity.clientProvided`), the id is
* `hash(protocol | conversation)` ONLY. The native session id is the only
* invariant tied to a conversation: the account credential rotates (OAuth
* bearer) and the upstream can change (relay) without the conversation
* changing, so neither may break continuity. The 4-way hash
* (protocol|upstream|credential|conversation) is kept for the
* content-fingerprint fallback, where the "conversation" is a content hash
* with a real collision surface and needs the account and upstream
* dimensions to stay isolated.
*/
export function deriveSessionId(
headers: Record<string, string | string[] | undefined>,
protocol: "anthropic" | "openai" | "responses",
upstream: string,
conversation: string,
identity?: ConversationIdentity,
): string {
if (!conversation) throw new Error("deriveSessionId: conversation dimension is required (pass the conversationSignal* output)");
if (identity?.clientProvided) return hashId(`${protocol}|${conversation}`);
const key = extractKey(headers);
return hashId(`${protocol}|${upstream}|${key}|${conversation}`);
}
Expand Down
55 changes: 55 additions & 0 deletions tests/proxy-session-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,61 @@ test("deriveSessionId: empty conversation dimension THROWS (no silent collapse)"
assert.throws(() => deriveSessionId({}, "anthropic", "https://up", ""), /conversation dimension is required/);
});

test("deriveSessionId: codex native session id stays stable across bearer rotation and relay switch (#286)", () => {
// Codex sends its per-conversation session-id header on every request;
// the OAuth bearer in Authorization rotates and the upstream can be a
// relay. Neither must mint a new session (issue #280 root cause).
const body = { input: [{ type: "message", role: "user", content: "hi" }] };
const identity = conversationIdentityResponses(body, "019fdc81-a420-7a00-bbd1-0a64e3eb772c")!;
assert.equal(identity.source, "header");
assert.equal(identity.clientProvided, true);
const a = deriveSessionId({ authorization: "Bearer oauth-token-1" }, "responses", "https://chatgpt.com", identity.value, identity);
const b = deriveSessionId({ authorization: "Bearer oauth-token-2" }, "responses", "https://chatgpt.com", identity.value, identity);
assert.equal(a, b, "rotating OAuth bearer must not break session continuity");
const c = deriveSessionId({ authorization: "Bearer oauth-token-1" }, "responses", "https://relay.example", identity.value, identity);
assert.equal(a, c, "relay/upstream switch must not break session continuity");
const d = deriveSessionId({ authorization: "Bearer oauth-token-1" }, "responses", "https://chatgpt.com", "different-session", identity);
assert.notEqual(a, d, "different conversation → different session");
const e = deriveSessionId({ authorization: "Bearer oauth-token-1" }, "anthropic", "https://chatgpt.com", identity.value, identity);
assert.notEqual(a, e, "different protocol → different session");
});

test("deriveSessionId: body session_id and prompt_cache_key identities are also credential-independent (#286)", () => {
const bodySession = conversationIdentityResponses({ input: [], session_id: "sess-1" } as never, undefined)!;
assert.equal(
deriveSessionId({ authorization: "Bearer k1" }, "responses", "https://chatgpt.com", bodySession.value, bodySession),
deriveSessionId({ authorization: "Bearer k2" }, "responses", "https://chatgpt.com", bodySession.value, bodySession),
);
const pck = preferPromptCacheKeyIdentity(conversationIdentityResponses({ input: [{ type: "message", role: "user", content: "x" }] }, undefined), { prompt_cache_key: "pck-omp-1" })!;
assert.equal(pck.source, "prompt-cache-key");
assert.equal(
deriveSessionId({ authorization: "Bearer k1" }, "responses", "https://up", pck.value, pck),
deriveSessionId({ authorization: "Bearer k2" }, "responses", "https://other-up", pck.value, pck),
);
});

test("deriveSessionId: content-fingerprint identity keeps the 4-way hash (isolation preserved) (#286)", () => {
const fp = conversationIdentityResponses({ input: [{ type: "message", role: "user", content: "anonymous opener" }] }, undefined)!;
assert.equal(fp.source, "content-fingerprint");
assert.equal(fp.clientProvided, false);
// No client signal: different accounts / upstreams must stay isolated.
assert.notEqual(
deriveSessionId({ authorization: "Bearer k1" }, "responses", "https://up", fp.value, fp),
deriveSessionId({ authorization: "Bearer k2" }, "responses", "https://up", fp.value, fp),
);
assert.notEqual(
deriveSessionId({ authorization: "Bearer k1" }, "responses", "https://up", fp.value, fp),
deriveSessionId({ authorization: "Bearer k1" }, "responses", "https://other", fp.value, fp),
);
// previous_response_id is clientDerived (clientProvided:false) → 4-way too.
const prev = conversationIdentityResponses({ input: [], previous_response_id: "resp_1" } as never, undefined)!;
assert.equal(prev.clientProvided, false);
assert.notEqual(
deriveSessionId({ authorization: "Bearer k1" }, "responses", "https://up", prev.value, prev),
deriveSessionId({ authorization: "Bearer k2" }, "responses", "https://up", prev.value, prev),
);
});

test("affinityToken: uses client signal when present (passthrough, preserves ses_ format)", () => {
const t = affinityToken({ value: "ses_opencode_xyz", source: "header", clientProvided: true });
assert.equal(t, "ses_opencode_xyz");
Expand Down
Loading