Skip to content
Merged
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
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,13 @@ it is on the wire.

Responses rejected at authentication or origin admission never reach this wrapper and carry no id.

Logged `POST /v1/chat/completions` replies also carry `x-opencodex-request-id`, exposed through
CORS, for JSON, streaming, handler errors and logged pre-handler refusals. This is the gateway
history id, independent of the response body's completion id. Caller and upstream values cannot
replace it. Rejections without a request-log owner, including authentication, origin, drain and
active-turn refusals, carry no id. Streaming headers identify the eventual history row before
final usage is saved.

Logged `POST /v1/messages` replies carry the same OCX request-history id in **both**
`request-id` and `x-opencodex-request-id`. This applies to native and translated replies,
streaming and JSON, and logged refusals. Claude Code can use its persisted `requestId`
Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@
"catalog-zero-credit-picker.test.ts": "codex-integration",
"chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses",
"chat-completions-endpoint.test.ts": "responses",
"chat-request-id-endpoint.test.ts": "responses",
"chat-completions-pool-mode.test.ts": "responses",
"chat-conversation-affinity.test.ts": "responses",
"chat-inbound-developer-position.test.ts": "responses",
Expand Down
16 changes: 11 additions & 5 deletions src/server/index/serve-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1618,11 +1618,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
// Promote a caller `x-session-id` before admission, as Responses and Messages do (#6520):
// the ChatGPT bridge forwards only canonical session headers, so the raw marker is lost.
const sessionReq = withCallerSessionIdentity(req, admission);
return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors(
await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
req,
policy,
), { requestId, start, logCtx });
// Only expose an id when the handler or a logged refusal owns its history row.
let ownsRequestLog = false;
const response = await runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => {
ownsRequestLog = true;
return withCors(
await handleChatCompletions(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
req,
policy,
);
}, { requestId, start, logCtx, onLogged: () => { ownsRequestLog = true; } });
return ownsRequestLog ? withRequestLogId(response, requestId) : response;
}

if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
Expand Down
9 changes: 8 additions & 1 deletion structure/data-planes/inbound-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,14 @@ its defaults and exclusions are owned by [Responses transport](../transports/res

The provider summary default applies at Responses ingress; native Chat and Anthropic inbound preferences keep their existing handling. Raw content is never renamed to a summary. See [bridge contract](../providers/chat-compat.md).

## Messages request-log correlation
## Chat and Messages request-log correlation

The Chat Completions HTTP boundary in `src/server/index/serve-options.ts` publishes its generated
request-history id as `x-opencodex-request-id`, exposed through CORS. JSON, SSE, handler errors
and logged pre-handler refusals share the final history row's id; caller and upstream ids cannot
replace it. Unlogged auth, origin, drain and active-turn refusals receive no correlation id.
Stream headers identify the eventual row, not completed persistence.
`tests/responses/chat-request-id-endpoint.test.ts` covers this boundary.

The Messages HTTP boundary in `src/server/index/serve-options.ts` publishes its generated
request-log id in both `request-id` and `x-opencodex-request-id`. Native, translated, streamed,
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@
"catalog-vision-sidecar-modalities.test.ts": "codex-integration",
"catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses",
"chat-completions-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses",
"chat-request-id-endpoint.test.ts": "responses",
"chat-conversation-affinity.test.ts": "responses", "chat-inbound-developer-position.test.ts": "responses",
"protocol-contract.test.ts": "responses", "protocol-features.test.ts": "responses",
"protocol-opaque-state.test.ts": "responses", "protocol-baseline.test.ts": "responses",
Expand Down
177 changes: 177 additions & 0 deletions tests/responses/chat-request-id-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, expect, test } from "bun:test";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../../src/config";
import { startServer } from "../../src/server";
import { MAX_ACTIVE_TURNS, tryAdmitTurn } from "../../src/server/lifecycle";
import { MAX_CONCURRENT_INBOUND_BODY_BYTES, withRaisedInboundBodyAdmission } from "../../src/server/inbound-body-admission";
import { readRecentUsageEntries } from "../../src/usage/log";
import { admitWorkflowTurn, chargeWorkflowSends, DEFAULT_WORKFLOW_BUDGET_POLICY, resetWorkflowBudgetsForTest } from "../../src/lib/workflow-budget";
import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home";
import { drainAndRemoveFixtureRoots } from "../helpers/fixture-teardown";
import type { OcxConfig } from "../../src/types";

let fixtureHome = "";
let previousHome: string | undefined;
let codexHome: IsolatedCodexHome | undefined;
let proxy: ReturnType<typeof startServer> | undefined;
let upstream: ReturnType<typeof Bun.serve> | undefined;
const origin = "http://fixture.example";

beforeEach(() => {
previousHome = process.env.OPENCODEX_HOME;
fixtureHome = mkdtempSync(join(tmpdir(), "ocx-chat-request-id-"));
process.env.OPENCODEX_HOME = fixtureHome;
codexHome = installIsolatedCodexHome("ocx-chat-id-codex-");
resetWorkflowBudgetsForTest();
});

afterEach(async () => {
await proxy?.stop(true);
proxy = undefined;
await upstream?.stop(true);
upstream = undefined;
resetWorkflowBudgetsForTest();
await drainAndRemoveFixtureRoots({
roots: [{ path: fixtureHome }, ...(codexHome ? [{ path: codexHome.path }] : [])],
restoreEnvironment() {
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
codexHome?.restoreEnvironment();
},
});
codexHome = undefined;
});

function launch(authenticated = false, status = 200, override: Partial<OcxConfig> = {}, hang = false) {
upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() {
if (status !== 200) return Response.json({ error: { message: "fixture refusal" } }, { status });
const frame = { id: "shared-provider-id", choices: [{ index: 0, delta: { content: "fixture answer" }, finish_reason: "stop" }],
usage: { prompt_tokens: 11, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 3 } } };
const body = hang ? new ReadableStream<Uint8Array>({ start(controller) {
controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ ...frame, choices: [{ index: 0, delta: { content: "OPEN" }, finish_reason: null }], usage: undefined })}\n\n`));
} }, { highWaterMark: 0 }) : `data: ${JSON.stringify(frame)}\n\ndata: [DONE]\n\n`;
return new Response(body, { headers: {
"content-type": "text/event-stream", "x-opencodex-request-id": "upstream-controlled",
} });
} });
saveConfig({ port: 0, hostname: authenticated ? "0.0.0.0" : "127.0.0.1", defaultProvider: "mock",
codexAutoStart: false, codexShimAutoRestore: false, connectTimeoutMs: 1000,
apiKeys: [{ id: "fixture-key-id", name: "fixture", key: "fixture-data-key", createdAt: "2026-01-01T00:00:00Z" }],
providers: { mock: { adapter: "openai-chat", baseUrl: `${upstream.url.toString().replace(/\/$/, "")}/v1`,
apiKey: "fixture-provider-key", allowPrivateNetwork: true, liveModels: false, models: ["echo"] } },
corsAllowOrigins: [origin], ...override,
} as OcxConfig);
proxy = startServer(0);
}

function send(stream = false, headers: Record<string, string> = {}, invalid = false, signal?: AbortSignal) {
return fetch(`http://127.0.0.1:${proxy!.port}/v1/chat/completions`, { method: "POST", signal, headers: {
"content-type": "application/json", authorization: "Bearer fixture-data-key", origin,
"x-opencodex-request-id": "caller-controlled", ...headers,
}, body: JSON.stringify({ model: "mock/echo", stream, messages: invalid ? null : [{ role: "user", content: "fixture question" }] }) });
}

async function durableRow(response: Response, expectedStatus: number) {
expect(response.status).toBe(expectedStatus);
const id = response.headers.get("x-opencodex-request-id");
expect(id).toMatch(/^ocx-[a-f0-9]{32}$/);
expect(response.headers.get("access-control-allow-origin")).toBe(origin);
const exposed = response.headers.get("access-control-expose-headers")!.toLowerCase().split(",").map(name => name.trim());
expect(exposed.filter(name => name === "x-opencodex-request-id")).toHaveLength(1);
const text = await response.text();
let rows = readRecentUsageEntries(100, fixtureHome).filter(row => row.requestId === id);
for (let i = 0; i < 100 && rows.length === 0; i++) {
await Bun.sleep(10);
rows = readRecentUsageEntries(100, fixtureHome).filter(row => row.requestId === id);
}
expect(rows).toHaveLength(1);
expect(rows[0]!.status).toBe(expectedStatus);
expect(rows[0]!.inboundProtocol).toBe("chat");
return { row: rows[0]!, text, id };
}

for (const authenticated of [false, true]) {
for (const stream of [false, true]) test(`${authenticated ? "authenticated" : "loopback"} Chat ${stream ? "SSE" : "JSON"} correlates to its history row`, async () => {
launch(authenticated);
const { row, text } = await durableRow(await send(stream), 200);
expect(text).toContain("fixture answer");
if (stream) expect(text).toContain("[DONE]");
else expect(JSON.parse(text).choices[0].message.content).toBe("fixture answer");
expect(row.usage).toEqual({ inputTokens: 11, outputTokens: 5, cachedInputTokens: 3 });
});
}

test("concurrent replies with identical upstream completion IDs have distinct history IDs", async () => {
launch();
const replies = await Promise.all(Array.from({ length: 3 }, () => send()));
const records = await Promise.all(replies.map(reply => durableRow(reply, 200)));
expect(new Set(records.map(record => record.id)).size).toBe(3);
});

test("handler-recorded invalid request has a history ID", async () => {
launch();
await durableRow(await send(false, {}, true), 400);
});

test("upstream HTTP error has a history ID", async () => {
launch(false, 503);
await durableRow(await send(), 503);
});

test("pre-handler workflow refusal has the logged history ID", async () => {
launch();
const admitted = admitWorkflowTurn("fixture-root", "interactive");
expect(admitted?.admitted).toBe(true);
if (admitted?.admitted) admitted.lease.release();
chargeWorkflowSends("fixture-root", DEFAULT_WORKFLOW_BUDGET_POLICY.maxPhysicalSends);
const response = await send(false, { "x-codex-parent-thread-id": "fixture-root" });
const { row } = await durableRow(response, 429);
expect(response.headers.get("access-control-expose-headers")).toContain("x-opencodex-local-refusal");
expect(row.errorCode).toBe(response.headers.get("x-opencodex-local-refusal"));
});

test("pre-handler body-capacity refusal has a history ID", async () => {
launch(false, 200, { maxInboundBodyBytes: MAX_CONCURRENT_INBOUND_BODY_BYTES });
const held = await withRaisedInboundBodyAdmission(new Request("http://localhost/v1/chat/completions", {
method: "POST", body: "{}",
}), "/v1/chat/completions", MAX_CONCURRENT_INBOUND_BODY_BYTES,
async () => new Response(new ReadableStream<Uint8Array>({}, { highWaterMark: 0 })));
try { await durableRow(await send(), 503); }
finally { await held.body!.cancel("fixture released"); }
});

test("stream cancellation keeps its eventual history ID", async () => {
launch(false, 200, {}, true);
const client = new AbortController();
try {
const response = await send(true, {}, false, client.signal);
const id = response.headers.get("x-opencodex-request-id");
expect(id).toMatch(/^ocx-[a-f0-9]{32}$/);
client.abort();
await response.body!.cancel().catch(() => {});
let row = readRecentUsageEntries(100, fixtureHome).find(entry => entry.requestId === id);
for (let i = 0; i < 100 && !row; i++) {
await Bun.sleep(10);
row = readRecentUsageEntries(100, fixtureHome).find(entry => entry.requestId === id);
}
expect(row?.status).toBe(499);
expect(row?.inboundProtocol).toBe("chat");
} finally { client.abort(); }
});

for (const rejection of ["auth", "origin", "active-turn"] as const) test(`${rejection} refusal has no fabricated history ID`, async () => {
launch(true);
const leases = rejection === "active-turn" ? Array.from({ length: MAX_ACTIVE_TURNS }, () => tryAdmitTurn()) : [];
try {
if (leases.length) expect(leases.every(Boolean)).toBe(true);
const before = readRecentUsageEntries(100, fixtureHome).length;
const response = await send(false, rejection === "auth" ? { authorization: "Bearer invalid-fixture-key" }
: rejection === "origin" ? { origin: "https://untrusted.invalid" } : {});
expect(response.status).toBe(rejection === "auth" ? 401 : rejection === "origin" ? 403 : 503);
expect(response.headers.has("x-opencodex-request-id")).toBe(false);
await response.text();
expect(readRecentUsageEntries(100, fixtureHome)).toHaveLength(before);
} finally { for (const lease of leases) lease?.release(); }
});
6 changes: 3 additions & 3 deletions tests/server/loopback-listener-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,13 @@ describe("local client inference wires on the loopback listener (#4236)", () =>
// It is now served on the loopback listener, so the public config must not be the source
// of its CORS headers — the same rule the Anthropic routes above already follow.
const chatStart = source.indexOf('url.pathname === "/v1/chat/completions"');
const nextRoute = source.indexOf("url.pathname === \"/v1/live\"", chatStart);
const nextRoute = source.indexOf('url.pathname === "/v1/audio/transcriptions"', chatStart);
expect(chatStart).toBeGreaterThan(-1);
const branch = source.slice(chatStart, nextRoute);
// The handler receives the caller-session-promoted request (#6520); CORS still uses req.
expect(branch).toContain("handleChatCompletions(sessionReq, config, logCtx");
expect(branch).toContain("req,\n policy,\n )");
expect(branch).not.toContain("req,\n config,\n )");
expect(branch).toMatch(/return withCors\(\s*await handleChatCompletions\([^\n]+\),\s*req,\s*policy,\s*\)/);
expect(branch).not.toMatch(/,\s*req,\s*config,\s*\)/);
});
});

Expand Down
Loading