From c0a4d9dd54d8586946756f537fc2413f144dc3f6 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 10:37:18 +0900 Subject: [PATCH 1/2] fix(chat): expose canonical request-history IDs Return the existing gateway request ID on Chat replies owned by a handler or logged admission refusal, preserving unlogged rejection behavior. Co-authored-by: daemons2000 <1758467+daemons2000@users.noreply.github.com> --- .../content/docs/reference/proxy-formats.md | 7 + scripts/test-layout/layout.json | 1 + src/server/index/serve-options.ts | 16 +- structure/data-planes/inbound-compat.md | 9 +- tests/fixtures/test-layout-expected.json | 1 + .../chat-request-id-endpoint.test.ts | 177 ++++++++++++++++++ 6 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 tests/responses/chat-request-id-endpoint.test.ts diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index b16260d19ee..6d245819c6e 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -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` diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index ef6d6d5c638..3b60ed94837 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -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", diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 72af800ba1a..56557344e91 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -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") { diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index ca39ab063b2..39feeb31e8a 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -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, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 9ae6ad80eb6..147fc1755a7 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -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", diff --git a/tests/responses/chat-request-id-endpoint.test.ts b/tests/responses/chat-request-id-endpoint.test.ts new file mode 100644 index 00000000000..5c8030bd8f5 --- /dev/null +++ b/tests/responses/chat-request-id-endpoint.test.ts @@ -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 | undefined; +let upstream: ReturnType | 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 = {}, 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({ 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 = {}, 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({}, { 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(); } +}); From 8fddf05b77b4f238465eb20928f63e5f95528f11 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 17 Oct 2026 10:47:50 +0900 Subject: [PATCH 2/2] test(chat): preserve CORS policy guard with receipt wrapper --- tests/server/loopback-listener-admission.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 4f980240ea0..438b79bfe73 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -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*\)/); }); });