fix: preserve conversation context on retry after previous_response_id error

This commit is contained in:
Hannes Rudolph 2025-09-05 09:52:32 -06:00
parent 1f5ee4307e
commit 8093615b8d
2 changed files with 158 additions and 16 deletions

View file

@ -859,6 +859,119 @@ describe("OpenAiNativeHandler", () => {
expect(secondCallBody.previous_response_id).toBe("resp_789")
})
it("should retry with full conversation when previous_response_id fails", async () => {
// Mock fetch for Responses API
const mockFetch = vitest
.fn()
.mockResolvedValueOnce({
// First call fails with invalid previous_response_id error
ok: false,
status: 400,
statusText: "Bad Request",
text: async () =>
JSON.stringify({
error: {
message: "Invalid previous_response_id: resp_old",
code: "invalid_previous_response_id",
},
}),
})
.mockResolvedValueOnce({
// Second call (retry) succeeds
ok: true,
body: new ReadableStream({
start(controller) {
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.output_item.added","item":{"type":"text","text":"Retry successful"}}\n\n',
),
)
controller.enqueue(
new TextEncoder().encode(
'data: {"type":"response.done","response":{"id":"resp_new","usage":{"prompt_tokens":100,"completion_tokens":2}}}\n\n',
),
)
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
},
}),
})
global.fetch = mockFetch as any
// Mock SDK to fail
mockResponsesCreate.mockRejectedValue(new Error("SDK not available"))
handler = new OpenAiNativeHandler({
...mockOptions,
apiModelId: "gpt-5-2025-08-07",
})
// Set up conversation with multiple messages
const conversationMessages: Anthropic.Messages.MessageParam[] = [
{ role: "user", content: "First message" },
{ role: "assistant", content: "First response" },
{ role: "user", content: "Second message" },
{ role: "assistant", content: "Second response" },
{ role: "user", content: "Latest message" },
]
// Try to create message with a previous_response_id that will fail
const stream = handler.createMessage(systemPrompt, conversationMessages, {
taskId: "test-task",
previousResponseId: "resp_old",
})
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have retried successfully
const textChunks = chunks.filter((c) => c.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Retry successful")
// Verify two fetch calls were made
expect(mockFetch).toHaveBeenCalledTimes(2)
// First call should have previous_response_id and only latest message
const firstCallBody = JSON.parse(mockFetch.mock.calls[0][1].body)
expect(firstCallBody.previous_response_id).toBe("resp_old")
expect(firstCallBody.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: "Latest message" }],
},
])
// Second call (retry) should NOT have previous_response_id and should have FULL conversation
const secondCallBody = JSON.parse(mockFetch.mock.calls[1][1].body)
expect(secondCallBody.previous_response_id).toBeUndefined()
expect(secondCallBody.instructions).toBe(systemPrompt)
expect(secondCallBody.input).toEqual([
{
role: "user",
content: [{ type: "input_text", text: "First message" }],
},
{
role: "assistant",
content: [{ type: "output_text", text: "First response" }],
},
{
role: "user",
content: [{ type: "input_text", text: "Second message" }],
},
{
role: "assistant",
content: [{ type: "output_text", text: "Second response" }],
},
{
role: "user",
content: [{ type: "input_text", text: "Latest message" }],
},
])
})
it("should only send latest message when using previous_response_id", async () => {
// Mock fetch for Responses API
const mockFetch = vitest

View file

@ -207,7 +207,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
)
// Make the request
yield* this.executeRequest(requestBody, model, metadata)
yield* this.executeRequest(requestBody, model, metadata, systemPrompt, messages)
}
private buildRequestBody(
@ -276,6 +276,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
requestBody: any,
model: OpenAiNativeModel,
metadata?: ApiHandlerCreateMessageMetadata,
systemPrompt?: string,
messages?: Anthropic.Messages.MessageParam[],
): ApiStream {
try {
// Use the official SDK
@ -297,18 +299,25 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
const errorMessage = sdkErr?.message || sdkErr?.error?.message || ""
const is400Error = sdkErr?.status === 400 || sdkErr?.response?.status === 400
const isPreviousResponseError =
errorMessage.includes("Previous response") || errorMessage.includes("not found")
errorMessage.includes("Previous response") ||
errorMessage.includes("not found") ||
errorMessage.includes("previous_response_id")
if (is400Error && requestBody.previous_response_id && isPreviousResponseError) {
// Log the error and retry without the previous_response_id
// Remove the problematic previous_response_id and retry
const retryRequestBody = { ...requestBody }
delete retryRequestBody.previous_response_id
// Clear the stored lastResponseId to prevent using it again
this.lastResponseId = undefined
// Re-prepare the request body with full conversation (no previous_response_id)
let retryRequestBody = { ...requestBody }
delete retryRequestBody.previous_response_id
// Re-prepare the input to send full conversation if we have the necessary data
if (systemPrompt && messages) {
// Re-prepare input without previous_response_id (will send full conversation)
const { formattedInput } = this.prepareStructuredInput(systemPrompt, messages, undefined)
retryRequestBody.input = formattedInput
}
try {
// Retry with the SDK
const retryStream = (await (this.client as any).responses.create(
@ -317,7 +326,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
if (typeof (retryStream as any)[Symbol.asyncIterator] !== "function") {
// If SDK fails, fall back to SSE
yield* this.makeGpt5ResponsesAPIRequest(retryRequestBody, model, metadata)
yield* this.makeGpt5ResponsesAPIRequest(
retryRequestBody,
model,
metadata,
systemPrompt,
messages,
)
return
}
@ -329,13 +344,13 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
return
} catch (retryErr) {
// If retry also fails, fall back to SSE
yield* this.makeGpt5ResponsesAPIRequest(retryRequestBody, model, metadata)
yield* this.makeGpt5ResponsesAPIRequest(retryRequestBody, model, metadata, systemPrompt, messages)
return
}
}
// For other errors, fallback to manual SSE via fetch
yield* this.makeGpt5ResponsesAPIRequest(requestBody, model, metadata)
yield* this.makeGpt5ResponsesAPIRequest(requestBody, model, metadata, systemPrompt, messages)
}
}
@ -424,6 +439,8 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
requestBody: any,
model: OpenAiNativeModel,
metadata?: ApiHandlerCreateMessageMetadata,
systemPrompt?: string,
messages?: Anthropic.Messages.MessageParam[],
): ApiStream {
const apiKey = this.options.openAiNativeApiKey ?? "not-provided"
const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com"
@ -463,20 +480,32 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
// Check if this is a 400 error about previous_response_id not found
const isPreviousResponseError =
errorDetails.includes("Previous response") || errorDetails.includes("not found")
errorDetails.includes("Previous response") ||
errorDetails.includes("not found") ||
errorDetails.includes("previous_response_id")
if (response.status === 400 && requestBody.previous_response_id && isPreviousResponseError) {
// Log the error and retry without the previous_response_id
// Remove the problematic previous_response_id and retry
const retryRequestBody = { ...requestBody }
delete retryRequestBody.previous_response_id
// Clear the stored lastResponseId to prevent using it again
this.lastResponseId = undefined
// Resolve the promise once to unblock any waiting requests
this.resolveResponseId(undefined)
// Re-prepare the input without previous_response_id to send the full conversation
let retryRequestBody = { ...requestBody }
delete retryRequestBody.previous_response_id
// If we have systemPrompt and messages, re-prepare the input to send full conversation
if (systemPrompt && messages) {
// Re-prepare input without previous_response_id (will send full conversation)
// Note: We pass undefined metadata to prepareStructuredInput to ensure it doesn't use previousResponseId
const { formattedInput } = this.prepareStructuredInput(systemPrompt, messages, undefined)
// Update the input in the retry request body to include full conversation
retryRequestBody.input = formattedInput
}
// Retry the request without the previous_response_id
const retryResponse = await fetch(url, {
method: "POST",