Skip to content

fix(openai): correlate rotated Responses API item ids - #18548

Merged
gr2m merged 1 commit into
mainfrom
codex/fix-openai-responses-id-rotation
Aug 6, 2026
Merged

gr2m merged 1 commit into
mainfrom
codex/fix-openai-responses-id-rotation

Conversation

@gr2m

@gr2m gr2m commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Background

@ai-sdk/openai keyed active Responses API reasoning state and emitted stream-part IDs by each event's opaque item_id. A real request to GitHub Copilot's /responses endpoint with gpt-5.3-codex showed that Copilot sends response.output_item.added, but rotates the opaque ID on every subsequent reasoning and message event. output_index remains stable.

That caused the reported activeReasoningPart.summaryParts crash. If the crash was bypassed, the same rotation produced the downstream text part ... not found error because text start, delta, and end used different IDs.

The committed fixture is the complete 69-event SSE sequence captured from the real provider on 2026-08-06. Opaque identifiers, encrypted reasoning payloads, obfuscation padding, and the safety identifier were sanitized; event order, event types, output indexes, deltas, usage, and response structure were preserved.

Summary

  • Retain output_index when parsing text-delta and reasoning-summary events.
  • Track the canonical message/reasoning item ID from response.output_item.added by output_index.
  • Use that canonical ID for reasoning/text deltas and end events, and avoid dereferencing absent reasoning state.
  • Add the sanitized live Copilot SSE capture as a regression fixture and assert that it emits no stream errors, preserves one ID per logical reasoning/text part, and returns the complete answer.
  • Add a public-API end-to-end example that serves the captured ID-rotation pattern over localhost SSE.
  • Add a patch changeset for @ai-sdk/openai.

Contributor Credit

Thanks to @A-S-Manoj for the upstream report and diagnosis; @drmgc and @EntropyParadigm for the Nanocoder reports; @daitmaamar4-png, @OverHash, and @jianminLee for independent downstream reports; and @mussaratshamsher for the related work in #18337.

End-to-End Verification

I first sent an authenticated request to the real GitHub Copilot Responses API using the credential created by nanocoder copilot login. Replaying that exact raw response through public createOpenAI(...).responses(...) and streamText(...) failed before this change with:

TypeError: Cannot read properties of undefined (reading 'summaryParts')

After rebuilding @ai-sdk/openai, replaying the same unsanitized raw response completed successfully.

I also ran the checked-in localhost HTTP/SSE reproduction:

cd examples/ai-functions
pnpm tsx src/stream-text/openai/responses-reasoning-id-rotation.ts

It now prints:

Expected: reasoning summary followed by "ok".
Thinking through the request.ok

This is the complete end-to-end example used for that verification:

import { createServer } from 'node:http';
import { createOpenAI } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { run } from '../../lib/run';

const modelId = 'gpt-5.3-codex';
const responseId = 'resp_copilot_repro';
const reasoningAddedId = 'rs_copilot_added';
const reasoningSummaryAddedId = 'rs_copilot_summary_added';
const reasoningSummaryDeltaId = 'rs_copilot_summary_delta';
const reasoningSummaryDoneId = 'rs_copilot_summary_done';
const reasoningDoneId = 'rs_copilot_done';
const messageAddedId = 'msg_copilot_added';
const messageDeltaId = 'msg_copilot_delta';
const messageDoneId = 'msg_copilot_done';

const response = {
  id: responseId,
  object: 'response',
  created_at: 1,
  status: 'in_progress',
  error: null,
  incomplete_details: null,
  input: [],
  instructions: null,
  max_output_tokens: null,
  model: modelId,
  output: [],
  parallel_tool_calls: true,
  previous_response_id: null,
  reasoning: { effort: 'low', summary: 'auto' },
  store: true,
  temperature: null,
  text: { format: { type: 'text' } },
  tool_choice: 'auto',
  tools: [],
  top_p: null,
  truncation: 'disabled',
  usage: null,
  user: null,
  metadata: {},
};

const reasoningItem = {
  id: reasoningDoneId,
  type: 'reasoning',
  summary: [{ type: 'summary_text', text: 'Thinking through the request.' }],
};

const messageItem = {
  id: messageDoneId,
  type: 'message',
  status: 'completed',
  role: 'assistant',
  content: [{ type: 'output_text', text: 'ok', annotations: [] }],
};

const events = [
  { type: 'response.created', response },

  // A live GitHub Copilot capture used a different opaque item id for every
  // reasoning and message event while keeping output_index stable.
  {
    type: 'response.output_item.added',
    output_index: 0,
    item: { id: reasoningAddedId, type: 'reasoning' },
  },
  {
    type: 'response.reasoning_summary_part.added',
    item_id: reasoningSummaryAddedId,
    output_index: 0,
    summary_index: 0,
  },
  {
    type: 'response.reasoning_summary_text.delta',
    item_id: reasoningSummaryDeltaId,
    output_index: 0,
    summary_index: 0,
    delta: 'Thinking through the request.',
  },
  {
    type: 'response.reasoning_summary_part.done',
    item_id: reasoningSummaryDoneId,
    output_index: 0,
    summary_index: 0,
  },
  {
    type: 'response.output_item.done',
    output_index: 0,
    item: reasoningItem,
  },
  {
    type: 'response.output_item.added',
    output_index: 1,
    item: { id: messageAddedId, type: 'message' },
  },
  {
    type: 'response.output_text.delta',
    item_id: messageDeltaId,
    output_index: 1,
    delta: 'ok',
  },
  {
    type: 'response.output_item.done',
    output_index: 1,
    item: messageItem,
  },
  {
    type: 'response.completed',
    response: {
      ...response,
      status: 'completed',
      output: [reasoningItem, messageItem],
      usage: {
        input_tokens: 5,
        input_tokens_details: { cached_tokens: 0 },
        output_tokens: 8,
        output_tokens_details: { reasoning_tokens: 7 },
        total_tokens: 13,
      },
    },
  },
];

function toSse(value: unknown) {
  return `data: ${JSON.stringify(value)}\n\n`;
}

async function startMockCopilotServer() {
  const server = createServer((request, response) => {
    if (request.method !== 'POST' || request.url !== '/v1/responses') {
      response.writeHead(404).end();
      return;
    }

    request.resume();
    request.on('end', () => {
      response.writeHead(200, {
        'content-type': 'text/event-stream',
        connection: 'close',
      });

      for (const event of events) {
        response.write(toSse(event));
      }

      response.end();
    });
  });

  await new Promise<void>((resolve, reject) => {
    server.once('error', reject);
    server.listen(0, '127.0.0.1', () => {
      server.off('error', reject);
      resolve();
    });
  });

  const address = server.address();
  if (address == null || typeof address === 'string') {
    throw new Error('Mock Copilot server did not bind to a TCP port.');
  }

  return {
    server,
    baseURL: `http://127.0.0.1:${address.port}/v1`,
  };
}

run(async () => {
  const { server, baseURL } = await startMockCopilotServer();

  try {
    const copilot = createOpenAI({
      apiKey: 'test-key',
      baseURL,
    });

    const result = streamText({
      model: copilot.responses(modelId),
      prompt: 'Reply with exactly: ok',
    });

    console.log('Expected: reasoning summary followed by "ok".');
    for await (const part of result.stream) {
      if (part.type === 'reasoning-delta' || part.type === 'text-delta') {
        process.stdout.write(part.text);
      }
    }
    console.log();
  } finally {
    await new Promise<void>((resolve, reject) => {
      server.close(error => {
        if (error != null) {
          reject(error);
          return;
        }

        resolve();
      });
    });
  }
});

Checklist

  • All commits are signed (PRs with unsigned commits cannot be merged)
  • Tests have been added / updated (for bug fixes / features)
  • Documentation has been added / updated (for bug fixes / features)
  • A patch changeset for relevant packages has been added (for bug fixes / features - run pnpm changeset in the project root)
  • I have reviewed this pull request (self-review)

Future Work

#18337 proposes synthesizing reasoning-start when a provider truly omits response.output_item.added. The real Copilot trace includes that event with a different ID, so this PR keeps that separate behavior out of scope. It can be evaluated independently if a raw provider trace confirms the missing-event variant.

Related Issues

Fixes #18270

@gr2m
gr2m merged commit 73d48d0 into main Aug 6, 2026
4 checks passed
@gr2m
gr2m deleted the codex/fix-openai-responses-id-rotation branch August 6, 2026 22:34
addyCooks pushed a commit to addyCooks/nanocoder that referenced this pull request Aug 10, 2026
Copilot rotates the opaque reasoning item id mid-stream while output_index
stays stable, as confirmed by the upstream trace in vercel/ai#18548.
Treating each rotated id as a new reasoning item split one item into
several and left the original never concluded.

Correlate on output_index instead: a rotated id is rewritten to the id
already announced at that index, so summary parts accumulate on a single
reasoning item that concludes properly. A reasoning item that was never
announced at all still gets a synthetic response.output_item.added.

Closes Nano-Collective#719
addyCooks added a commit to addyCooks/nanocoder that referenced this pull request Aug 10, 2026
Copilot rotates the opaque reasoning item id mid-stream while output_index
stays stable, as confirmed by the upstream trace in vercel/ai#18548.
Treating each rotated id as a new reasoning item split one item into
several and left the original never concluded.

Correlate on output_index instead: a rotated id is rewritten to the id
already announced at that index, so summary parts accumulate on a single
reasoning item that concludes properly. A reasoning item that was never
announced at all still gets a synthetic response.output_item.added.

Closes Nano-Collective#719
addyCooks added a commit to addyCooks/nanocoder that referenced this pull request Aug 10, 2026
Copilot rotates the opaque reasoning item id mid-stream while output_index
stays stable, as confirmed by the upstream trace in vercel/ai#18548.
Treating each rotated id as a new reasoning item split one item into
several and left the original never concluded.

Correlate on output_index instead: a rotated id is rewritten to the id
already announced at that index, so summary parts accumulate on a single
reasoning item that concludes properly. A reasoning item that was never
announced at all still gets a synthetic response.output_item.added.

Closes Nano-Collective#719
addyCooks added a commit to addyCooks/nanocoder that referenced this pull request Aug 12, 2026
Copilot rotates the opaque reasoning item id mid-stream while output_index
stays stable, as confirmed by the upstream trace in vercel/ai#18548.
Treating each rotated id as a new reasoning item split one item into
several and left the original never concluded.

Correlate on output_index instead: a rotated id is rewritten to the id
already announced at that index, so summary parts accumulate on a single
reasoning item that concludes properly. A reasoning item that was never
announced at all still gets a synthetic response.output_item.added.

Closes Nano-Collective#719
will-lamerton pushed a commit to Nano-Collective/nanocoder that referenced this pull request Aug 13, 2026
#835)

* fix: announce rotated Copilot reasoning item ids before the SDK parses them

GitHub Copilot's Responses API proxy emits reasoning summary events under
an item_id it never announced via response.output_item.added — either a
rotated id or no announcement at all. The OpenAI Responses stream parser
assumes that state exists and dereferences it, so the stream dies with
"Cannot read properties of undefined (reading 'summaryParts')".

The repository already carries a defensive patch for this in
patches/@[email protected], but pnpm patchedDependencies is a
workspace-local install setting: it is not published, and dist is not
bundled, so every npm/brew user resolves @ai-sdk/openai straight from the
registry. ^3.0.71 currently floats to 3.0.91, which still has the
unguarded dereferences, which is why the crash survived the patch.

Normalizes the Copilot SSE stream instead, in code that actually ships:
when a reasoning event references an unannounced item_id, a synthetic
response.output_item.added is emitted ahead of it so the parser always
has state. Healthy streams pass through byte for byte.

Closes #719

* fix: map rotated Copilot reasoning ids to the item at that output_index

Copilot rotates the opaque reasoning item id mid-stream while output_index
stays stable, as confirmed by the upstream trace in vercel/ai#18548.
Treating each rotated id as a new reasoning item split one item into
several and left the original never concluded.

Correlate on output_index instead: a rotated id is rewritten to the id
already announced at that index, so summary parts accumulate on a single
reasoning item that concludes properly. A reasoning item that was never
announced at all still gets a synthetic response.output_item.added.

Closes #719

* refactor: collapse the duplicated reasoning-id branches into one path

The reasoning-item and reasoning-summary branches each carried their own
copy of the announce, rewrite and synthesize logic. Resolve the item id
once up front, then run a single path for all three outcomes.

Behaviour is unchanged: the same 25 unit tests pass, and the streams
verified against an unpatched @ai-sdk/openai produce identical results.

* fix: normalize Copilot reasoning ids regardless of SSE framing

The normalizer parsed SSE more strictly than the parser directly behind
it. `EventSourceParserStream` accepts LF, CRLF and lone CR, and ignores
any `event:` line in front of the payload; anything it accepts that we
rejected was an event we silently failed to fix.

- rewrite() now operates on an SSE block rather than assuming the block
  is a single `data:` line, so an `event:`-framed stream is normalized
  instead of passed straight through. Replacements are written in place,
  leaving `event:`/`id:` lines intact.
- Blocks split on /\r\n\r\n|\n\n|\r\r/ and are re-emitted with their own
  separator. Splitting on '\n\n' alone never matched a CRLF stream, so
  the whole body buffered until close and streaming output stalled.
- flush() runs the trailing buffer through rewrite(), so a final event
  with no trailing separator is normalized too.
- A synthetic announcement is only emitted when `output_index` is a
  number. It is required by the SDK schema, and a chunk that fails
  validation sets finishReason to 'error' rather than being skipped, so
  announcing without one traded the crash for a broken response. The
  event is passed through untouched instead, and nothing is recorded as
  announced, so a later well-formed event can still be repaired.

Adds coverage for `event:` framing, CRLF framing, a CRLF separator split
across chunk boundaries, a trailing event with no separator, and a
missing `output_index`. All seven fail against the previous
implementation.
AryanNandanwar pushed a commit to AryanNandanwar/nanocoder that referenced this pull request Aug 13, 2026
Nano-Collective#835)

* fix: announce rotated Copilot reasoning item ids before the SDK parses them

GitHub Copilot's Responses API proxy emits reasoning summary events under
an item_id it never announced via response.output_item.added — either a
rotated id or no announcement at all. The OpenAI Responses stream parser
assumes that state exists and dereferences it, so the stream dies with
"Cannot read properties of undefined (reading 'summaryParts')".

The repository already carries a defensive patch for this in
patches/@[email protected], but pnpm patchedDependencies is a
workspace-local install setting: it is not published, and dist is not
bundled, so every npm/brew user resolves @ai-sdk/openai straight from the
registry. ^3.0.71 currently floats to 3.0.91, which still has the
unguarded dereferences, which is why the crash survived the patch.

Normalizes the Copilot SSE stream instead, in code that actually ships:
when a reasoning event references an unannounced item_id, a synthetic
response.output_item.added is emitted ahead of it so the parser always
has state. Healthy streams pass through byte for byte.

Closes Nano-Collective#719

* fix: map rotated Copilot reasoning ids to the item at that output_index

Copilot rotates the opaque reasoning item id mid-stream while output_index
stays stable, as confirmed by the upstream trace in vercel/ai#18548.
Treating each rotated id as a new reasoning item split one item into
several and left the original never concluded.

Correlate on output_index instead: a rotated id is rewritten to the id
already announced at that index, so summary parts accumulate on a single
reasoning item that concludes properly. A reasoning item that was never
announced at all still gets a synthetic response.output_item.added.

Closes Nano-Collective#719

* refactor: collapse the duplicated reasoning-id branches into one path

The reasoning-item and reasoning-summary branches each carried their own
copy of the announce, rewrite and synthesize logic. Resolve the item id
once up front, then run a single path for all three outcomes.

Behaviour is unchanged: the same 25 unit tests pass, and the streams
verified against an unpatched @ai-sdk/openai produce identical results.

* fix: normalize Copilot reasoning ids regardless of SSE framing

The normalizer parsed SSE more strictly than the parser directly behind
it. `EventSourceParserStream` accepts LF, CRLF and lone CR, and ignores
any `event:` line in front of the payload; anything it accepts that we
rejected was an event we silently failed to fix.

- rewrite() now operates on an SSE block rather than assuming the block
  is a single `data:` line, so an `event:`-framed stream is normalized
  instead of passed straight through. Replacements are written in place,
  leaving `event:`/`id:` lines intact.
- Blocks split on /\r\n\r\n|\n\n|\r\r/ and are re-emitted with their own
  separator. Splitting on '\n\n' alone never matched a CRLF stream, so
  the whole body buffered until close and streaming output stalled.
- flush() runs the trailing buffer through rewrite(), so a final event
  with no trailing separator is normalized too.
- A synthetic announcement is only emitted when `output_index` is a
  number. It is required by the SDK schema, and a chunk that fails
  validation sets finishReason to 'error' rather than being skipped, so
  announcing without one traded the crash for a broken response. The
  event is passed through untouched instead, and nothing is recorded as
  announced, so a later well-formed event can still be repaired.

Adds coverage for `event:` framing, CRLF framing, a CRLF separator split
across chunk boundaries, a trailing event with no separator, and a
missing `output_index`. All seven fail against the previous
implementation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TypeError: Cannot read properties of undefined (reading 'summaryParts') when streaming reasoning summaries via GitHub Copilot's Responses API

1 participant