fix: add retry logic with conversation truncation for HTTP 400 errors in OpenAI Compatible API

- Implement automatic retry with progressive conversation history truncation when HTTP 400 errors occur
- Add retry logic to both streaming and non-streaming methods
- Truncate older messages while keeping at least 10 most recent for context
- Add comprehensive test coverage for retry scenarios
- Fixes issue #9188 where Qwen3-Coder-30B-A3B model was prone to HTTP 400 errors after multiple conversation rounds
This commit is contained in:
Roo Code 2025-11-12 05:57:12 +00:00
parent 69d4efc335
commit 669b8127a9
2 changed files with 273 additions and 7 deletions

View file

@ -0,0 +1,218 @@
// npx vitest run api/providers/__tests__/base-openai-compatible-provider-retry.spec.ts
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
import type { ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../../shared/api"
// Mock the OpenAI client
vi.mock("openai")
// Create a concrete implementation for testing
class TestProvider extends BaseOpenAiCompatibleProvider<"test-model"> {
constructor(options: ApiHandlerOptions) {
super({
providerName: "TestProvider",
baseURL: "https://test.api.com/v1",
defaultProviderModelId: "test-model",
providerModels: {
"test-model": {
maxTokens: 4096,
contextWindow: 8192,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
} as ModelInfo,
},
...options,
})
}
}
describe("BaseOpenAiCompatibleProvider - HTTP 400 Retry Logic", () => {
let provider: TestProvider
let mockCreate: ReturnType<typeof vi.fn>
let consoleWarnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
// Mock the OpenAI client's create method
mockCreate = vi.fn()
;(OpenAI as any).mockImplementation(() => ({
chat: {
completions: {
create: mockCreate,
},
},
}))
// Spy on console.warn to verify retry messages
consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {})
provider = new TestProvider({
apiKey: "test-key",
})
})
afterEach(() => {
vi.clearAllMocks()
consoleWarnSpy.mockRestore()
})
describe("createStream", () => {
it("should retry with truncated conversation history on HTTP 400 error", async () => {
// First call fails with 400, second call succeeds
mockCreate
.mockRejectedValueOnce({ status: 400, message: "Bad Request" })
.mockResolvedValueOnce(createMockStream())
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = Array.from({ length: 20 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: `Message ${i}`,
}))
const stream = await provider["createStream"](systemPrompt, messages)
// Verify the stream was returned
expect(stream).toBeDefined()
// Verify retry was attempted
expect(mockCreate).toHaveBeenCalledTimes(2)
// Verify warning was logged
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining(
"[TestProvider] Received HTTP 400 error, retrying with truncated conversation history",
),
)
// Verify second call had truncated messages
const secondCallParams = mockCreate.mock.calls[1][0]
expect(secondCallParams.messages.length).toBeLessThan(21) // system + 20 messages originally
})
it("should progressively truncate more messages on multiple retries", async () => {
// All calls fail with 400 to test progressive truncation
mockCreate
.mockRejectedValueOnce({ status: 400, message: "Bad Request" })
.mockRejectedValueOnce({ response: { status: 400 } })
.mockRejectedValueOnce(new Error("400 Bad Request"))
.mockRejectedValueOnce({ status: 400 }) // Final failure
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = Array.from({ length: 30 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: `Message ${i}`,
}))
await expect(provider["createStream"](systemPrompt, messages)).rejects.toThrow()
// Verify all retry attempts were made
expect(mockCreate).toHaveBeenCalledTimes(4) // Initial + 3 retries
// Verify progressive truncation
const messageCounts = mockCreate.mock.calls.map((call) => call[0].messages.length)
expect(messageCounts[0]).toBe(31) // system + 30 messages
expect(messageCounts[1]).toBeLessThan(messageCounts[0]) // First truncation
expect(messageCounts[2]).toBeLessThan(messageCounts[1]) // Second truncation
// The third truncation might hit the minimum of 10 messages, so it could be equal
expect(messageCounts[3]).toBeLessThanOrEqual(messageCounts[2]) // Third truncation or minimum reached
})
it("should not retry on non-400 errors", async () => {
mockCreate.mockRejectedValueOnce({ status: 500, message: "Internal Server Error" })
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
await expect(provider["createStream"](systemPrompt, messages)).rejects.toThrow()
// Should not retry
expect(mockCreate).toHaveBeenCalledTimes(1)
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
it("should not truncate if conversation has 10 or fewer messages", async () => {
mockCreate
.mockRejectedValueOnce({ status: 400, message: "Bad Request" })
.mockResolvedValueOnce(createMockStream())
const systemPrompt = "You are a helpful assistant"
const messages: Anthropic.Messages.MessageParam[] = Array.from({ length: 5 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: `Message ${i}`,
}))
await provider["createStream"](systemPrompt, messages)
// Both calls should have the same number of messages
const firstCallParams = mockCreate.mock.calls[0][0]
const secondCallParams = mockCreate.mock.calls[1][0]
expect(firstCallParams.messages.length).toBe(6) // system + 5 messages
expect(secondCallParams.messages.length).toBe(6) // No truncation
})
})
describe("completePrompt", () => {
it("should retry with truncated prompt on HTTP 400 error", async () => {
const mockResponse = {
choices: [{ message: { content: "Response" } }],
}
mockCreate
.mockRejectedValueOnce({ status: 400, message: "Bad Request" })
.mockResolvedValueOnce(mockResponse)
const longPrompt = "a".repeat(2000) // Long prompt
const result = await provider.completePrompt(longPrompt)
expect(result).toBe("Response")
expect(mockCreate).toHaveBeenCalledTimes(2)
// Verify second call had truncated prompt
const secondCallPrompt = mockCreate.mock.calls[1][0].messages[0].content
expect(secondCallPrompt.length).toBeLessThan(longPrompt.length)
})
it("should not retry for short prompts", async () => {
mockCreate.mockRejectedValueOnce({ status: 400, message: "Bad Request" })
const shortPrompt = "Hello"
await expect(provider.completePrompt(shortPrompt)).rejects.toThrow()
// Should not retry for short prompts
expect(mockCreate).toHaveBeenCalledTimes(1)
})
it("should handle provider-specific error responses", async () => {
const mockResponse = {
base_resp: {
status_code: 1001,
status_msg: "Provider specific error",
},
choices: [],
}
mockCreate.mockResolvedValueOnce(mockResponse)
await expect(provider.completePrompt("Test")).rejects.toThrow(
"TestProvider API Error (1001): Provider specific error",
)
})
})
})
// Helper function to create a mock stream
function createMockStream() {
return {
async *[Symbol.asyncIterator]() {
yield {
choices: [{ delta: { content: "Test" } }],
usage: { prompt_tokens: 10, completion_tokens: 5 },
}
},
}
}

View file

@ -64,12 +64,13 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
})
}
protected createStream(
protected async createStream(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
requestOptions?: OpenAI.RequestOptions,
) {
retryCount: number = 0,
): Promise<AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>> {
const { id: model, info } = this.getModel()
// Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply)
@ -83,18 +84,48 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
const temperature = this.options.modelTemperature ?? this.defaultTemperature
// Convert messages and potentially truncate if we're retrying due to a 400 error
let convertedMessages = convertToOpenAiMessages(messages)
// If this is a retry and we have many messages, try truncating older conversation history
// Keep at least the last 10 messages to maintain context
if (retryCount > 0 && convertedMessages.length > 10) {
const truncationRatio = Math.min(0.5 + retryCount * 0.1, 0.8) // Truncate 50%, 60%, 70%, up to 80%
const messagesToKeep = Math.max(10, Math.floor(convertedMessages.length * (1 - truncationRatio)))
const truncatedMessages = convertedMessages.slice(-messagesToKeep)
console.warn(
`[${this.providerName}] Truncating conversation history due to HTTP 400 error. Keeping last ${messagesToKeep} of ${convertedMessages.length} messages.`,
)
convertedMessages = truncatedMessages
}
const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model,
max_tokens,
temperature,
messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
messages: [{ role: "system", content: systemPrompt }, ...convertedMessages],
stream: true,
stream_options: { include_usage: true },
}
try {
return this.client.chat.completions.create(params, requestOptions)
} catch (error) {
return await this.client.chat.completions.create(params, requestOptions)
} catch (error: any) {
// Check if this is a 400 error that might be due to conversation length
const is400Error =
error?.status === 400 ||
error?.response?.status === 400 ||
(error?.message && error.message.includes("400"))
// Retry with truncated history if we haven't exceeded max retries
if (is400Error && retryCount < 3) {
console.warn(
`[${this.providerName}] Received HTTP 400 error, retrying with truncated conversation history (attempt ${retryCount + 1}/3)`,
)
return this.createStream(systemPrompt, messages, metadata, requestOptions, retryCount + 1)
}
throw handleOpenAIError(error, this.providerName)
}
}
@ -154,7 +185,7 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
}
}
async completePrompt(prompt: string): Promise<string> {
async completePrompt(prompt: string, retryCount: number = 0): Promise<string> {
const { id: modelId } = this.getModel()
try {
@ -172,7 +203,24 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
}
return response.choices?.[0]?.message.content || ""
} catch (error) {
} catch (error: any) {
// Check if this is a 400 error that might be due to prompt length
const is400Error =
error?.status === 400 ||
error?.response?.status === 400 ||
(error?.message && error.message.includes("400"))
// Retry with truncated prompt if we haven't exceeded max retries
if (is400Error && retryCount < 3 && prompt.length > 1000) {
const truncationRatio = Math.min(0.5 + retryCount * 0.1, 0.8)
const truncatedPrompt = prompt.substring(0, Math.floor(prompt.length * (1 - truncationRatio)))
console.warn(
`[${this.providerName}] Received HTTP 400 error in completePrompt, retrying with truncated prompt (attempt ${retryCount + 1}/3)`,
)
return this.completePrompt(truncatedPrompt, retryCount + 1)
}
throw handleOpenAIError(error, this.providerName)
}
}