Skip to content

[Bug]: Chat Completions replies omit the gateway request-history ID #7257

Closed

Description

@daemons2000

Client or integration

Direct HTTP/API client and an SDK-based workflow client.

Area

Proxy and routing

Summary

Chat Completions responses omit the canonical gateway request-history ID, even when the gateway records the call. The included patch returns that existing ID so clients can match responses to usage records without guessing from timestamps.

Outcome

Chat Completions replies should return the gateway request-history ID, so a client
can match each response to its exact usage record.

In OpenCodeX 2.86.0, the gateway records the call but does not return that ID.
This prevents reliable accounting for clients using /v1/chat/completions.
Matching nearby timestamps, model names or token counts is unsafe when calls overlap.
The response body's completion ID is not the gateway history ID.

Scope

The Chat route in src/server/index/serve-options.ts already creates requestId
and passes it to handleChatCompletions, but only returns withCors(...).
The Responses route already uses withRequestLogId(...). The Messages route has
a similar log-ownership check, added for #6815 in #6831.

I checked the current dev source before filing and the Chat route still has the
same gap. Searches for chat request-id and x-opencodex-request-id did not find
an exact Chat report. The Messages issue above is related.

The proposed fix uses the existing helper and log callback. It returns
x-opencodex-request-id when the Chat handler or a logged refusal owns the history
row. It leaves authentication and origin rejections without a misleading receipt
ID. No provider credentials, routing rules, response bodies or usage calculations
are changed.

Reproduction

  1. Configure any working Chat Completions provider.
  2. Send a JSON or streaming POST /v1/chat/completions request.
  3. Consume the response and inspect x-opencodex-request-id.
  4. Before the fix, the header is absent even though request history contains the call.
  5. With the fix, the returned ID resolves through authenticated
    GET /api/request-history/<id> to that exact call.

Proposed fix

This complete patch applies to v2.86.0 (commit
409026b1bcb184e523ccc4f22930dc0a5a6ffa01):

--- a/src/server/index/serve-options.ts
+++ b/src/server/index/serve-options.ts
@@ -1610,11 +1610,17 @@
         // 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") {

The log-ownership check matters: simply wrapping the handler fixes ordinary
responses but misses refusals logged before the handler runs. The test below
caught that omission in my initial patch.

Validation

I applied the patch to the installed 2.86.0 package and restarted the gateway.
Six real requests passed: JSON and SSE calls on each of two independently
configured provider routes, plus one SDK-based workflow call on each route. Every actual returned header ID
resolved to its matching canonical history row. Each row reported the expected
provider/model, one physical send, one settlement, no unresolved sends and
reported token usage. A live malformed-request control returned a matching ID
for its logged HTTP 400 response as well.

The isolated offline fixture passed 10 loopback cases and 11 authenticated-listener
cases. These cover JSON/SSE bodies, token/cache usage, CORS visibility, spoofed
IDs, overlapping calls with identical provider completion IDs, local 400 errors,
upstream 503 errors, client cancellation/499 and a logged workflow refusal/429.
It also checks that authentication/origin rejections have no receipt ID and that
the existing Responses and Messages headers still work. The pristine source
fails on missing Chat headers; the initial simple wrapper fails the logged-refusal
case. The final patch passes both listener modes.

I did not run the full upstream suite. Live tests did not force provider failures
or demonstrate a real cache hit; those behaviors were checked with a local fake
provider. This report makes no invoice verification or complete collector claim.

Runnable regression fixture

Save the following as verify-chat-request-id.cjs. Use Bun and a disposable
copy of the package with its dependencies installed. The third argument must be
a fresh private directory. The fixture uses only a local fake provider and rejects
nonlocal fetch destinations. It never needs real provider credentials.

bun verify-chat-request-id.cjs /absolute/package-copy /tmp/new-chat-receipt-home
bun verify-chat-request-id.cjs /absolute/package-copy /tmp/new-chat-auth-home authenticated
Complete regression fixture
/** Real HTTP regression against an isolated package, with a local fake provider only. */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');

const source = path.resolve(process.argv[2]);
const home = path.resolve(process.argv[3]);
const authenticated = process.argv[4] === 'authenticated';
const hostname = authenticated ? '127.0.0.2' : '127.0.0.1';
process.env.OPENCODEX_HOME = home;
process.env.CODEX_HOME = path.join(home, 'codex');
const fetchLocal = globalThis.fetch;
globalThis.fetch = (input, options) => {
  const url = new URL(typeof input === 'string' || input instanceof URL ? input : input.url);
  if (!['127.0.0.1', '127.0.0.2'].includes(url.hostname))
    throw new Error('Offline fixture refused a nonlocal request');
  return fetchLocal(input, options);
};

(async () => {
  assert.ok(!fs.existsSync(home), 'Use a fresh private fixture home');
  fs.mkdirSync(home, { recursive: true, mode: 0o700 });
  const held = new Set();
  const results = [];
  const receiptIds = new Set();
  let server;
  let token;
  let base;
  const upstream = Bun.serve({
    hostname: '127.0.0.1', port: 0,
    async fetch(request) {
      if (new URL(request.url).pathname.endsWith('/models'))
        return Response.json({ data: [{ id: 'echo' }] });
      const body = await request.json();
      const content = JSON.stringify(body.messages);
      if (content.includes('provider-failure'))
        return Response.json({ error: { message: 'Synthetic provider failure', type: 'server_error' } }, { status: 503 });
      const usage = { prompt_tokens: 11, completion_tokens: 5, total_tokens: 16, prompt_tokens_details: { cached_tokens: 3 } };
      const headers = { 'x-opencodex-request-id': 'upstream-spoof', 'x-private-provider-header': 'must-not-be-forwarded' };
      if (!body.stream)
        return Response.json({ id: 'shared-provider-id', object: 'chat.completion', model: 'echo', choices: [{ index: 0, message: { role: 'assistant', content: 'OK' }, finish_reason: 'stop' }], usage }, { headers });
      const frame = { id: 'shared-provider-id', object: 'chat.completion.chunk', model: 'echo', choices: [{ index: 0, delta: { content: 'OK' }, finish_reason: 'stop' }], usage };
      if (!content.includes('hold-stream'))
        return new Response(`data: ${JSON.stringify(frame)}\n\ndata: [DONE]\n\n`, { headers: { ...headers, 'content-type': 'text/event-stream' } });
      let entry;
      const stream = new ReadableStream({
        start(controller) {
          entry = controller; held.add(controller);
          controller.enqueue(new TextEncoder().encode(`data: ${JSON.stringify({ ...frame, choices: [{ index: 0, delta: { content: 'OPEN' }, finish_reason: null }], usage: undefined })}\n\n`));
        },
        cancel() { held.delete(entry); },
      });
      return new Response(stream, { headers: { ...headers, 'content-type': 'text/event-stream' } });
    },
  });
  /** Read a management endpoint with a bounded deadline, keeping its token out of output. */
  async function management(route) {
    const response = await fetch(`${base}${route}`, { headers: { authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(10000) });
    return { status: response.status, data: await response.json() };
  }
  /** Poll only for an already-owned receipt; never search by time/model/token coincidence. */
  async function receipt(id, expectedStatus) {
    const deadline = Date.now() + 5000;
    do {
      const response = await management(`/api/request-history/${id}`);
      const row = response.data.entry || response.data;
      if (response.status === 200 && (expectedStatus === undefined || row.status === expectedStatus)) {
        assert.equal(row.requestId, id);
        return row;
      }
      await new Promise(resolve => setTimeout(resolve, 25));
    } while (Date.now() < deadline);
    assert.fail(`Receipt did not settle with expected status ${expectedStatus}`);
  }
  /** Send Chat traffic through public HTTP, with a caller-spoofed ID on every request. */
  function chat(content = 'probe', options = {}) {
    return fetch(`${base}/v1/chat/completions`, {
      method: 'POST', signal: options.signal || AbortSignal.timeout(10000),
      headers: { 'content-type': 'application/json', 'x-opencodex-request-id': 'caller-spoof', ...(authenticated ? { authorization: 'Bearer fixture-data-key' } : {}), ...options.headers },
      body: JSON.stringify({ model: 'fixture/echo', messages: options.invalid ? null : [{ role: 'user', content }], stream: !!options.stream, ...(options.stream ? { stream_options: { include_usage: true } } : {}) }),
    });
  }
  /** Check the trusted ID and CORS exposure without consuming or replacing the body. */
  function header(response) {
    const id = response.headers.get('x-opencodex-request-id');
    assert.match(id || '', /^ocx-[0-9a-f]{32}$/, 'Reply must expose its canonical request-history ID');
    assert.ok(!receiptIds.has(id), 'Different requests must have distinct receipt IDs');
    receiptIds.add(id);
    assert.ok((response.headers.get('access-control-expose-headers') || '').toLowerCase().split(',').map(x => x.trim()).includes('x-opencodex-request-id'));
    assert.equal(response.headers.get('x-private-provider-header'), null);
    return id;
  }
  /** Retain all independent case outcomes so one missing header does not hide other regressions. */
  async function test(name, fn) {
    try { await fn(); results.push({ name, passed: true }); }
    catch (error) { results.push({ name, passed: false, error: error.message }); }
  }
  try {
    const { getDefaultConfig } = await import(pathToFileURL(path.join(source, 'src/config/proxy-env.ts')));
    fs.writeFileSync(path.join(home, 'config.json'), JSON.stringify({
      ...getDefaultConfig(), hostname, port: 0, codexAutoStart: false, codexShimAutoRestore: false,
      apiKeys: [{ id: 'fixture-key-id', name: 'fixture', key: 'fixture-data-key', createdAt: '2026-10-16T00:00:00Z' }],
      defaultProvider: 'fixture',
      providers: { fixture: { adapter: 'openai-chat', baseUrl: `http://127.0.0.1:${upstream.port}/v1`, apiKey: 'synthetic-provider-key', authMode: 'key', allowPrivateNetwork: true, models: ['echo'], liveModels: false } },
    }), { mode: 0o600 });
    const { loadConfig } = await import(pathToFileURL(path.join(source, 'src/config.ts')));
    assert.equal(loadConfig().providers.fixture?.baseUrl, `http://127.0.0.1:${upstream.port}/v1`, 'Config must load without fallback');
    const { startServer } = await import(pathToFileURL(path.join(source, 'src/server/index.ts')));
    server = startServer(0); base = `http://${hostname}:${server.port}`;
    token = fs.readFileSync(path.join(home, 'admin-api-token'), 'utf8').trim();
    for (const stream of [false, true]) await test(stream ? 'streaming success, body, usage and CORS' : 'JSON success, body, usage and CORS', async () => {
      const response = await chat('probe', { stream, headers: { origin: 'http://localhost:3099' } });
      const text = await response.text(); assert.equal(response.status, 200);
      const id = header(response); assert.ok(text.includes('shared-provider-id')); assert.ok(text.includes('OK'));
      if (stream) assert.ok(text.includes('[DONE]'));
      const row = await receipt(id, 200);
      assert.deepEqual(row.usage, { inputTokens: 11, outputTokens: 5, cachedInputTokens: 3 });
      assert.deepEqual(row.spend, { sends: 1, settled: 1, unresolved: 0 });
    });
    for (const [name, content, options, status] of [['invalid request', 'probe', { invalid: true }, 400], ['provider HTTP failure', 'provider-failure', {}, 503]]) await test(name, async () => {
      const response = await chat(content, options); await response.text(); assert.equal(response.status, status);
      const row = await receipt(header(response), status);
      assert.equal(row.usageStatus, 'unreported');
      if (status === 503) assert.equal(row.attempts[0].sendCount, 1);
    });
    await test('concurrent identical completions have distinct canonical receipts', async () => {
      const responses = await Promise.all(Array.from({ length: 4 }, () => chat()));
      for (const response of responses) { const text = await response.text(); assert.equal(response.status, 200); assert.ok(text.includes('shared-provider-id')); await receipt(header(response), 200); }
    });
    await test('origin rejection has no receipt ID', async () => {
      const response = await chat('probe', { headers: { origin: 'https://untrusted.invalid' } });
      await response.text(); assert.equal(response.status, 403); assert.equal(response.headers.get('x-opencodex-request-id'), null);
    });
    if (authenticated) await test('authentication rejection has no receipt ID', async () => {
      const response = await chat('probe', { headers: { authorization: 'Bearer invalid-fixture-key' } });
      await response.text(); assert.equal(response.status, 401); assert.equal(response.headers.get('x-opencodex-request-id'), null);
    });
    for (const wire of ['responses', 'messages']) await test(`${wire} sibling receipt contract remains intact`, async () => {
      const body = wire === 'responses'
        ? { model: 'fixture/echo', input: 'probe', stream: false }
        : { model: 'fixture/echo', messages: [{ role: 'user', content: 'probe' }], max_tokens: 64, stream: false };
      const response = await fetch(`${base}/v1/${wire}`, { method: 'POST', headers: { 'content-type': 'application/json', ...(authenticated ? { authorization: 'Bearer fixture-data-key' } : {}) }, body: JSON.stringify(body), signal: AbortSignal.timeout(10000) });
      await response.text(); assert.equal(response.status, 200);
      const id = header(response);
      if (wire === 'messages') assert.equal(response.headers.get('request-id'), id);
      await receipt(id, 200);
    });
    await test('client cancellation keeps its receipt and status', async () => {
      const controller = new AbortController();
      try {
        const response = await chat('hold-stream', { stream: true, signal: controller.signal });
        const id = header(response); controller.abort();
        await response.body.cancel().catch(() => undefined);
        const row = await receipt(id, 499); assert.equal(row.usageStatus, 'unreported');
      } finally { controller.abort(); }
    });
    await test('logged workflow refusal has a receipt before the handler runs', async () => {
      const controllers = [];
      const responses = [];
      try {
        // Seven workers consume the default eight-slot root's seven worker slots.
        for (let i = 0; i < 7; i += 1) {
          const controller = new AbortController(); controllers.push(controller);
          const response = await chat('hold-stream', { stream: true, signal: controller.signal, headers: { 'x-codex-parent-thread-id': 'fixture-budget-root', 'thread-id': `fixture-child-${i}` } });
          assert.equal(response.status, 200); responses.push(response);
        }
        const response = await chat('probe', { headers: { 'x-codex-parent-thread-id': 'fixture-budget-root', 'thread-id': 'fixture-child-refused' } });
        await response.text(); assert.equal(response.status, 429);
        await receipt(header(response), 429);
      } finally {
        controllers.forEach(controller => controller.abort());
        await Promise.all(responses.map(response => response.body.cancel().catch(() => undefined)));
      }
    });
    console.log(JSON.stringify({ mode: authenticated ? 'authenticated' : 'loopback', results, cases: results.length, passed: results.filter(result => result.passed).length, receiptIds: [...receiptIds] }));
    assert.ok(results.every(result => result.passed), 'All HTTP contract cases must pass');
  } finally {
    for (const controller of held) { try { controller.close(); } catch {} }
    if (server) await server.stop(true);
    await upstream.stop(true);
  }
})().catch(error => { console.error(error); process.exitCode = 1; });

Acceptance criteria

  • Logged Chat replies expose the exact gateway history ID in the header and CORS.
  • This includes streaming replies, handler errors and logged admission refusals.
  • The returned ID resolves to the corresponding canonical history row.
  • Caller and upstream IDs cannot replace the gateway's own identity.
  • Unlogged rejections have no receipt ID.
  • Bodies, cancellation, routing and the existing endpoint contracts still work.

Out of scope

This does not reconstruct old uncorrelated calls. A streaming receipt header
identifies the eventual row; it does not promise final usage has already been
saved when the headers arrive.

Version

@bitkyc08/opencodex 2.86.0; base tag v2.86.0, commit 409026b. Current dev was also inspected before filing.

Operating system

Linux (WSL 2).

Provider and model

Provider-independent: the omission is in the inbound Chat route. Verified with two independently configured OpenAI-compatible provider routes and the local fake provider in the included fixture.

Logs or error output

Before patch: successful Chat response, missing x-opencodex-request-id, history row still persisted. After patch: six successful live calls and a local HTTP 400 control returned IDs resolving to their exact history rows. Offline results: 10/10 loopback cases and 11/11 authenticated-listener cases passed. The initial handler-only patch failed the logged workflow-refusal case, which the supplied patch fixes.

Screenshots and supporting files

The full unified diff and full runnable regression fixture are included inline above. No external repository or attachment is needed to recover the proposed fix.

Redacted configuration

The reproduction uses a custom openai-chat provider pointing at a local fake HTTP server; the complete isolated configuration is constructed in the fixture above. Live verification used two provider routes without changing admission, provider configuration or credentials.

Checks

  • I searched existing issues and documentation.
  • I removed secrets, tokens, account details, request credentials, and personal data.

Activity

  1. github-actions commented on Oct 16, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  2. added
    bugSomething isn't working
    proxyHTTP proxy, routing, reverse-proxy / management auth
    on Oct 16, 2026
  3. changed the title [-]fix(server): return canonical request IDs on Chat Completions replies[/-] [+][Bug]: Chat Completions replies omit the gateway request-history ID[/+] on Oct 16, 2026
  4. lidge-jun commented on Oct 17, 2026

    @lidge-jun
    Owner

    Fixed on dev by #7261: Chat Completions replies now carry x-opencodex-request-id (exposed through CORS) for handler-owned responses and logged refusals. Ships in 2.88.0. Thanks for the report and the proposed patch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingproxyHTTP proxy, routing, reverse-proxy / management auth

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions