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
3 changes: 3 additions & 0 deletions scripts/test-layout/layout-claude-bridge.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"claude-bridge-pool-session.test.ts": "claude-integration"
}
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"version": 1,
"root": "tests",
"explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json"],
"explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json", "layout-claude-bridge.json"],
"explicit": { "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows",
"stop-restore-history-ownership.test.ts": "service",
"cli-stop-refusal-transport.test.ts": "cli",
Expand Down
6 changes: 6 additions & 0 deletions src/server/caller-session-identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ export function withCallerSessionIdentity(req: Request, admission: DataPlaneAdmi
}
const headers = new Headers(req.headers);
headers.set("session_id", sessionId);
// A root caller also names its Codex pool identity, as history tools
// do (contextSelectionHeaders); a declared parent keeps its upstream lineage key.
if (!headers.get("x-codex-parent-thread-id")?.trim()) {
headers.set("session-id", sessionId);
headers.set("thread-id", sessionId);
}
// Preserve the unread body stream and propagate the original request's abort signal.
return new Request(req, { headers });
}
40 changes: 40 additions & 0 deletions src/server/claude-bridge-pool-session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* One Claude session keeps one pool account.
*
* The Messages bridge replays each turn on a fresh internal /v1/responses request. Unless the
* caller sent a complete identity, its headers carry no bindable thread, so `codexPoolAffinityKey`
* returns no key, the Codex pool never binds the turn, and round-robin with sticky limit 1 moves
* every turn to the next account.
*
* The bridge passes the Claude session it already sends upstream as `session_id`
* (`withClaudeNativeSession`): the uuid form of the metadata.user_id hash, or else Claude Code's
* `x-claude-code-session-id` header. The identity is completed only when the Codex key would stay
* undefined. A caller session alias names the session. A caller thread, a parent or an already
* bindable identity is explicit and left unchanged. The shared
* system-prompt cohort is never a session.
*/
import { codexPoolAffinityKey } from "../codex/auth-context";
import { contextPrincipalIdOf, type DataPlaneAdmission } from "./auth-cors";
import { safeCallerSessionId } from "./caller-session-identity";

const CALLER_SESSION_ALIASES = ["session-id", "session_id"] as const;

/** Claude Code's session header, scoped to the admitted principal exactly like x-session-id at ingress. */
export function claudeHeaderSessionId(headers: Headers, admission: DataPlaneAdmission | undefined): string | undefined {
const session = safeCallerSessionId(headers.get("x-claude-code-session-id"));
if (!session || !admission || admission.kind === "loopback") return session;
const principal = contextPrincipalIdOf(admission);
if (!principal) return undefined;
return new Bun.CryptoHasher("sha256")
.update(JSON.stringify(["opencodex-caller-session-v1", principal, session])).digest("hex");
}

export function withClaudeBridgePoolSession(headers: Headers, claudeSessionId: string | undefined): void {
if (codexPoolAffinityKey(headers) !== undefined || headers.get("x-codex-parent-thread-id")?.trim() || headers.get("thread-id")?.trim()) return;
// A non-empty invalid caller alias suppresses the weaker Claude identity, as on native egress.
const alias = CALLER_SESSION_ALIASES.map(name => headers.get(name)?.trim()).find(value => !!value);
const sessionId = safeCallerSessionId(alias ?? claudeSessionId ?? null);
if (!sessionId) return;
if (!headers.get("session-id")?.trim()) headers.set("session-id", sessionId);
headers.set("thread-id", sessionId);
}
3 changes: 3 additions & 0 deletions src/server/claude-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* unchanged. The Responses output (SSE or JSON) is converted back to Anthropic shape.
*/
import { FORWARD_HEADERS } from "../adapters/openai-responses";
import { claudeHeaderSessionId, withClaudeBridgePoolSession } from "./claude-bridge-pool-session";
import {
admissionModelDeniedResponse,
AdmissionModelDeniedError,
Expand Down Expand Up @@ -1173,6 +1174,8 @@ async function handleClaudeMessagesWithBudget(
?? normalizeLogConversationId(req.headers.get("x-opencode-session"))
?? metadataGoLane
?? getOrAllocateRequestSessionLane(req);
// The replay names its Claude session, so every pool binds the turn to it.
withClaudeBridgePoolSession(headers, claudeNativeSessionId ?? claudeHeaderSessionId(req.headers, logIds?.admission));
let internalReq: Request;
try {
// The UTF-16 JSON string and the Request's UTF-8 body coexist until dispatch.
Expand Down
10 changes: 9 additions & 1 deletion structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ caller values are forwarded like an explicit `session_id`, without principal sco
promoted `x-session-id`. Original ingress headers and affinity computation are unchanged, as are
custom and API-key destinations. No identity or originator is invented for marker-free requests.

`src/server/caller-session-identity.ts` promotes validated `x-session-id` on HTTP Responses, Messages and Chat Completions before turn admission in `src/server/index/serve-options.ts`. Explicit `session_id`, `session-id`, or `thread-id` presence wins, including empty values; managed Grok promotion runs first on Responses. The trimmed marker must start with an ASCII letter/digit, contain only letters, digits, dots, underscores, colons or hyphens, and stay within 128 characters. Loopback admission keeps it; authenticated admission scopes it with the trusted credential principal into an opaque SHA-256 identifier and skips promotion without that principal. Bodies and abort signals are preserved, and the original Request owns Bun timeout lookup. This provides continuity, not authorization or guaranteed cache hits. Existing explicit/Grok identities, WebSocket frames, compact and count_tokens retain their behavior.
`src/server/caller-session-identity.ts` promotes validated `x-session-id` on HTTP Responses, Messages and Chat Completions before turn admission in `src/server/index/serve-options.ts`. Explicit `session_id`, `session-id`, or `thread-id` presence wins, including empty values; managed Grok promotion runs first on Responses. The trimmed marker must start with an ASCII letter/digit, contain only letters, digits, dots, underscores, colons or hyphens, and stay within 128 characters. Loopback admission keeps it; authenticated admission scopes it with the trusted credential principal into an opaque SHA-256 identifier and skips promotion without that principal. Bodies and abort signals are preserved, and the original Request owns Bun timeout lookup. A promoted root caller (no `x-codex-parent-thread-id`) also names `session-id` and `thread-id` with the same scoped value, so `codexPoolAffinityKey` binds it like a Codex thread; a declared parent keeps its upstream lineage key. This provides continuity, not authorization or guaranteed cache hits. Existing explicit/Grok identities, WebSocket frames, compact and count_tokens retain their behavior.

## Chat conversation identity forwarding

Expand Down Expand Up @@ -389,6 +389,14 @@ canonical Go transport, including combo selection and failover. No Go-only repla
reaches non-Go destinations. Shared system cache keys never become conversation identity.
The request-scoped fallback is stable across retries and distinct across client requests.

The Messages bridge replays each turn on an internal Responses request. When that request would
carry no Codex pool key, no declared parent and no caller `thread-id`, `src/server/claude-bridge-pool-session.ts` completes
`session-id` and `thread-id` from the caller's session alias, else the validated Claude session
(metadata, else `x-claude-code-session-id`, scoped to the admitted principal like `x-session-id`).
An invalid non-empty alias names no session, and the shared system-prompt cohort is never one.
`tests/claude-integration/claude-bridge-pool-session.test.ts` replays recorded alternation through
the Codex, Anthropic and generic OAuth pool resolvers.

Claude metadata also supplies a private native-session value. Only the final canonical ChatGPT
attempt receives it, in copied forwarding headers; an explicit underscore session, hyphenated
session or thread header suppresses synthesis. Refresh and alternate-account retries retain
Expand Down
Loading
Loading