Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions src/api/providers/__tests__/openai-native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }],
},
])
Copy link
Contributor

Choose a reason for hiding this comment

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

Great test coverage for the main scenario! Consider adding edge case tests for:

  • What happens if the retry also fails with a different error?
  • Behavior when systemPrompt or messages are undefined during retry

This would ensure the fix is robust in all scenarios.

})

it("should only send latest message when using previous_response_id", async () => {
// Mock fetch for Responses API
const mockFetch = vitest
Expand Down
59 changes: 44 additions & 15 deletions src/api/providers/openai-native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider adding debug logging here when a retry occurs due to invalid previous_response_id. This would be helpful for monitoring and debugging in production environments.


// 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) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I notice there's duplicate retry logic here and in makeGpt5ResponsesAPIRequest() (lines 487-531). Could we extract this into a shared helper method to reduce duplication and ensure consistency?

Something like:

// Re-prepare input without previous_response_id (will send full conversation)
const { formattedInput } = this.prepareStructuredInput(systemPrompt, messages, undefined)
retryRequestBody.input = formattedInput
Comment on lines +315 to +318
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

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

Consider adding an else clause to handle the case where systemPrompt or messages are undefined. Without proper fallback handling, the retry might still send incomplete data if these parameters are missing.

Suggested change
if (systemPrompt && messages) {
// Re-prepare input without previous_response_id (will send full conversation)
const { formattedInput } = this.prepareStructuredInput(systemPrompt, messages, undefined)
retryRequestBody.input = formattedInput
retryRequestBody.input = formattedInput
} else {
// If we don't have the necessary data, fall back to SSE immediately
yield* this.makeGpt5ResponsesAPIRequest(
retryRequestBody,
model,
metadata,
systemPrompt,
messages,
)
return

Copilot uses AI. Check for mistakes.
}

try {
// Retry with the SDK
Expand All @@ -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
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
}
Comment on lines +500 to +507
Copy link

Copilot AI Sep 5, 2025

Choose a reason for hiding this comment

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

This logic is duplicated from lines 315-319. Consider extracting this retry preparation logic into a separate method to avoid code duplication and improve maintainability.

Copilot uses AI. Check for mistakes.

// Retry the request without the previous_response_id
const retryResponse = await fetch(url, {
method: "POST",
Expand Down
Loading