From 8093615b8d392efaad670997cc6ecac85d1f8c0d Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 5 Sep 2025 09:52:32 -0600 Subject: [PATCH] fix: preserve conversation context on retry after previous_response_id error --- .../providers/__tests__/openai-native.spec.ts | 113 ++++++++++++++++++ src/api/providers/openai-native.ts | 59 ++++++--- 2 files changed, 157 insertions(+), 15 deletions(-) diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 97499acce3..4ec96dda6a 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -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 diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index c884091c02..99de53d646 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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,17 +299,24 @@ 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 + // Clear the stored lastResponseId to prevent using it again + this.lastResponseId = undefined - // Remove the problematic previous_response_id and retry - const retryRequestBody = { ...requestBody } + // Re-prepare the request body with full conversation (no previous_response_id) + let retryRequestBody = { ...requestBody } delete retryRequestBody.previous_response_id - // Clear the stored lastResponseId to prevent using it again - this.lastResponseId = undefined + // 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 @@ -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",