fix(provider/openai): skip duplicate items when previousResponseId is set in Responses API#15492
Closed
p3droml wants to merge 1 commit into
Closed
Conversation
… 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>
5 tasks
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) { | |||
Collaborator
There was a problem hiding this comment.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Background
When using the Responses API with
providerOptions.openai.previousResponseIdandstore: true, the converter emits prior assistant items asitem_references ininputwhile those same items are already present in the server-side chain viaprevious_response_id. OpenAI rejects this with:How to reproduce
A reasoning model (
gpt-5/gpt-5.1/ etc.) usingstreamTextwith tools andpreviousResponseIdadvanced per step viaprepareStep/onStepFinish— the documented pattern for preserving reasoning state across reasoning-model tool calls. See the OpenAI cookbook: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 commit000fa96/ PR #11899). This change introduces the symmetrichasPreviousResponseIdflag and applies it to the same five skip sites:At the call site in
openai-responses-language-model.ts:The five existing skip conditions become
if ((hasConversation || hasPreviousResponseId) && id != null)(or the equivalent for the unconditional tool-result skip).Compatibility
conversationnorpreviousResponseIdis set.conversationusers.previousResponseIdusers withstore: true, this is the fix — they would previously have hitDuplicate item found400s 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.tsmirror the existinghasConversationcoverage (text, tool-call, reasoning, compaction). All 714 existing tests in@ai-sdk/openaicontinue to pass on both node and edge environments.Changeset
.changeset/openai-responses-skip-dup-items-previous-response-id.md—@ai-sdk/openai: patch.Related
hasConversationfix: commit000fa96, PR fix(openai): filter duplicate items when passing conversationID #11899Made with Cursor