mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
69d4efc335
commit
669b8127a9
2 changed files with 273 additions and 7 deletions
|
|
@ -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 },
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue