Skip to content

fix(provider/openai): skip duplicate items when previousResponseId is set in Responses API#15492

Closed
p3droml wants to merge 1 commit into
vercel:mainfrom
p3droml:fix/openai-responses-skip-dup-items-on-previous-response-id
Closed

fix(provider/openai): skip duplicate items when previousResponseId is set in Responses API#15492
p3droml wants to merge 1 commit into
vercel:mainfrom
p3droml:fix/openai-responses-skip-dup-items-on-previous-response-id

Conversation

@p3droml
Copy link
Copy Markdown

@p3droml p3droml commented May 20, 2026

Background

When using the Responses API with providerOptions.openai.previousResponseId and store: true, the converter emits prior assistant items as item_references in input while those same items are already present in the server-side chain via previous_response_id. OpenAI rejects this with:

Error [AI_APICallError]: Duplicate item found with id rs_0c44cfe6e9bf2d84006a0e434c5a70819bb8b57fbf1d10c2df.
Remove duplicate items from your input and try again.
url: https://api.openai.com/v1/responses
statusCode: 400

How to reproduce

A reasoning model (gpt-5 / gpt-5.1 / etc.) using streamText with tools and previousResponseId advanced per step via prepareStep / onStepFinish — the documented pattern for preserving reasoning state across reasoning-model tool calls. See the OpenAI cookbook:

If you use previous_response_id for multi-turn conversations, the model will automatically have access to all previously produced reasoning items.

Today, doing this with the AI SDK fails because the converter doesn't realize the assistant items are already on the server side.

Fix

The same dedup already exists for the Conversations API via hasConversation (added in commit 000fa96 / PR #11899). This change introduces the symmetric hasPreviousResponseId flag and applies it to the same five skip sites:

  1. assistant text parts with itemId
  2. assistant tool-call parts with itemId
  3. assistant tool-result parts (provider-executed tools)
  4. assistant reasoning parts with itemId
  5. assistant compaction parts with itemId

At the call site in openai-responses-language-model.ts:

hasConversation: openaiOptions?.conversation != null,
hasPreviousResponseId: openaiOptions?.previousResponseId != null,

The five existing skip conditions become if ((hasConversation || hasPreviousResponseId) && id != null) (or the equivalent for the unconditional tool-result skip).

Compatibility

  • Behavior is unchanged when neither conversation nor previousResponseId is set.
  • Behavior is unchanged for existing conversation users.
  • For previousResponseId users with store: true, this is the fix — they would previously have hit Duplicate item found 400s as soon as any chained assistant item had an itemId.

Tests

4 new tests in packages/openai/src/responses/convert-to-openai-responses-input.test.ts mirror the existing hasConversation coverage (text, tool-call, reasoning, compaction). All 714 existing tests in @ai-sdk/openai continue to pass on both node and edge environments.

Changeset

.changeset/openai-responses-skip-dup-items-previous-response-id.md@ai-sdk/openai: patch.

Related

Made with Cursor

… set

When using the Responses API with `providerOptions.openai.previousResponseId`
and `store: true`, the converter emitted prior assistant items as
`item_reference`s in `input` while those same items were already present
in the chain via `previous_response_id`. OpenAI rejects this with
`400 Duplicate item found with id rs_...`.

The same dedup already existed for `conversation` via the `hasConversation`
flag (commit 000fa96 / PR vercel#11899). This change introduces the symmetric
`hasPreviousResponseId` flag and applies it to the same five skip sites
(assistant text, assistant tool-call, assistant tool-result, reasoning,
compaction), set to `openaiOptions?.previousResponseId != null` at the
call site.

Behavior is unchanged when neither `conversation` nor `previousResponseId`
is set, and unchanged for existing `conversation` users.

Reported pattern: multi-step tool loops using `streamText` with
`previousResponseId` advanced per step via `prepareStep` / `onStepFinish`
(the documented way to preserve reasoning state across reasoning-model
tool calls — see https://cookbook.openai.com/examples/responses_api/reasoning_items).

Tests: 4 new tests in `convert-to-openai-responses-input.test.ts` mirror
the existing `hasConversation` coverage (text, tool-call, reasoning,
compaction). All 714 existing tests in `@ai-sdk/openai` continue to pass
on both node and edge.

Co-authored-by: Cursor <cursoragent@cursor.com>
Comment on lines 236 to +613
@@ -260,7 +264,7 @@ export async function convertToOpenAIResponsesInput({
| string
| undefined;

if (hasConversation && id != null) {
if ((hasConversation || hasPreviousResponseId) && id != null) {
break;
}

@@ -409,7 +413,7 @@ export async function convertToOpenAIResponsesInput({
break;
}

if (hasConversation) {
if (hasConversation || hasPreviousResponseId) {
break;
}

@@ -505,7 +509,10 @@ export async function convertToOpenAIResponsesInput({

const reasoningId = providerOptions?.itemId;

if (hasConversation && reasoningId != null) {
if (
(hasConversation || hasPreviousResponseId) &&
reasoningId != null
) {
break;
}

@@ -603,7 +610,7 @@ export async function convertToOpenAIResponsesInput({
part.providerOptions?.[providerOptionsName];
const id = providerOpts?.itemId as string | undefined;

if (hasConversation && id != null) {
if ((hasConversation || hasPreviousResponseId) && id != null) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the PR but in my testing i found that it doesn't need to be skipped at all the 5 call sites. only function call and reasoning items are good enough to be skipped. see #15503.

will be marking this as closed in favor of that. the repro script i attached first reproduces the error, and then the fix gets rid of it

aayush-kapoor added a commit that referenced this pull request May 21, 2026
…e id (#15503)

## Background

#15502 / reported in
#15492

when `previousResponseId` is used with the openai provider, with `store:
true`, we observe that duplicate reasoning items and function call items
are being passed and the provider throws a 404 error.

[openai docs
highlight](https://developers.openai.com/cookbook/examples/responses_api/reasoning_items#:~:text=If%20you%20use%20previous_response_id%20for%20multi%2Dturn%20conversations%2C%20the%20model%20will%20automatically%20have%20access%20to%20all%20previously%20produced%20reasoning%20items.)
that:

> If you use previous_response_id for multi-turn conversations, the
model will automatically have access to all previously produced
reasoning items

so it's fine if we skip passing those items under those conditions

## Summary

- introduced a flag `hasPreviousResponseId` and if it does - we skip
passing ONLY the reasoning items and function calls

## Manual Verification

verified by running the repro in the issue: 

<details>
<summary>repro:</summary>

```ts
import {
  openai,
  type OpenaiResponsesProviderMetadata,
  type OpenAILanguageModelResponsesOptions,
} from '@ai-sdk/openai';
import { isStepCount, streamText, tool } from 'ai';
import { z } from 'zod';
import { run } from '../../lib/run';

run(async () => {
  let previousResponseId: string | undefined;

  const result = streamText({
    model: openai.responses('gpt-5-mini'),
    maxRetries: 0,
    stopWhen: isStepCount(2),
    tools: {
      getWeather: tool({
        description: 'Get the weather in a city.',
        inputSchema: z.object({
          city: z.string().describe('The city to get the weather for.'),
        }),
        execute: async ({ city }) => ({
          city,
          weather: 'sunny',
          temperature: 72,
        }),
      }),
    },
    prompt: 'Use the weather tool for San Francisco, then answer briefly.',
    reasoning: 'low',
    include: {
      requestBody: true,
    },
    prepareStep: ({ stepNumber }) => ({
      toolChoice:
        stepNumber === 0 ? { type: 'tool', toolName: 'getWeather' } : 'auto',
      providerOptions: {
        openai: {
          store: true,
          ...(previousResponseId != null ? { previousResponseId } : {}),
        } satisfies OpenAILanguageModelResponsesOptions,
      },
    }),
    onStepFinish: step => {
      const providerMetadata = step.providerMetadata as
        | OpenaiResponsesProviderMetadata
        | undefined;

      previousResponseId =
        providerMetadata?.openai.responseId ?? previousResponseId;

      console.log('Step response ID:', previousResponseId);
    },
  });

  for await (const chunk of result.fullStream) {
    switch (chunk.type) {
      case 'start-step':
        console.log(
          'Request body:',
          JSON.stringify(chunk.request.body, null, 2),
        );
        break;

      case 'tool-call':
        console.log('Tool call:', chunk.toolName, chunk.input);
        break;

      case 'tool-result':
        console.log('Tool result:', chunk.output);
        break;

      case 'text-delta':
        process.stdout.write(chunk.text);
        break;

      case 'error':
        throw chunk.error;
    }
  }
});
```
</details>

## Checklist

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

## Related Issues

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants