refactor: stop wrapping AI SDK errors, surface real provider error messages

- Remove handleAiSdkError() wrapping from all 24 provider files — typed AI SDK
  errors (APICallError, RetryError, TooManyRequestsError) now flow through directly
- Update Task.ts to use APICallError.isInstance/RetryError.isInstance for typed
  error handling, extractAiSdkErrorMessage() for user display
- Add findDeepestApiCallError() for recursive .cause/.lastError/.errors[] traversal
- Fix processAiSdkStreamPart error case to use extractAiSdkErrorMessage instead of
  flattening to error.message (was losing responseBody with actual error details)
- Fix extractMessageFromResponseBody to handle OpenRouter metadata.raw with nested
  Anthropic error format (rawObj.error.message)
- Add checkIsAiSdkContextWindowError() for structured context window detection
- Update backoffAndAnnounce() to read .statusCode from APICallError natively
- Fix 25 provider error tests, add 8 new error extraction tests
- Remove dead handleOpenAIError export from error-handler.ts
- Add lastStreamError recovery to OpenRouter and native-ollama providers
This commit is contained in:
Hannes Rudolph 2026-02-13 12:53:17 -07:00
parent b6fd74b3ec
commit 0bd02aeb46
43 changed files with 554 additions and 262 deletions

View file

@ -335,7 +335,7 @@ describe("AzureHandler", () => {
for await (const chunk of stream) {
chunks.push(chunk)
}
}).rejects.toThrow("Azure AI Foundry")
}).rejects.toThrow("API Error")
})
})

View file

@ -414,7 +414,7 @@ describe("BasetenHandler", () => {
for await (const _ of stream) {
// consume stream
}
}).rejects.toThrow("Baseten: API Error")
}).rejects.toThrow("API Error")
})
it("should preserve status codes in error handling", async () => {
@ -439,7 +439,7 @@ describe("BasetenHandler", () => {
}
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error.message).toContain("Baseten")
expect(error.message).toContain("Rate limit exceeded")
expect(error.status).toBe(429)
}
})

View file

@ -237,11 +237,11 @@ describe("AwsBedrockHandler Error Handling", () => {
})
// -----------------------------------------------------------------------
// Non-throttling errors (createMessage) are wrapped by handleAiSdkError
// Non-throttling errors (createMessage) propagate unchanged
// -----------------------------------------------------------------------
describe("Non-throttling errors (createMessage)", () => {
it("should wrap non-throttling errors with provider name via handleAiSdkError", async () => {
it("should propagate non-throttling errors unchanged", async () => {
const genericError = createMockError({
message: "Something completely unexpected happened",
})
@ -256,7 +256,7 @@ describe("AwsBedrockHandler Error Handling", () => {
for await (const _chunk of generator) {
// should throw
}
}).rejects.toThrow("Bedrock: Something completely unexpected happened")
}).rejects.toThrow("Something completely unexpected happened")
})
it("should preserve status code from non-throttling API errors", async () => {
@ -277,8 +277,7 @@ describe("AwsBedrockHandler Error Handling", () => {
}
throw new Error("Expected error to be thrown")
} catch (error: any) {
expect(error.message).toContain("Bedrock:")
expect(error.message).toContain("Internal server error occurred")
expect(error.message).toBe("Internal server error occurred")
}
})
@ -298,7 +297,7 @@ describe("AwsBedrockHandler Error Handling", () => {
for await (const _chunk of generator) {
// should throw
}
}).rejects.toThrow("Bedrock: Too many tokens in request")
}).rejects.toThrow("Too many tokens in request")
})
})
@ -334,7 +333,7 @@ describe("AwsBedrockHandler Error Handling", () => {
}).rejects.toThrow("Bedrock is unable to process your request")
})
it("should wrap non-throttling errors that occur mid-stream via handleAiSdkError", async () => {
it("should propagate non-throttling errors that occur mid-stream unchanged", async () => {
const genericError = createMockError({
message: "Some other error",
status: 500,
@ -357,22 +356,22 @@ describe("AwsBedrockHandler Error Handling", () => {
for await (const _chunk of generator) {
// should throw
}
}).rejects.toThrow("Bedrock: Some other error")
}).rejects.toThrow("Some other error")
})
})
// -----------------------------------------------------------------------
// completePrompt errors — all go through handleAiSdkError (no throttle check)
// completePrompt errors — propagate unchanged (no throttle check)
// -----------------------------------------------------------------------
describe("completePrompt error handling", () => {
it("should wrap errors with provider name for completePrompt", async () => {
it("should propagate errors unchanged for completePrompt", async () => {
mockGenerateText.mockRejectedValueOnce(new Error("Bedrock API failure"))
await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: Bedrock API failure")
await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock API failure")
})
it("should wrap throttling-pattern errors with provider name for completePrompt", async () => {
it("should propagate throttling-pattern errors unchanged for completePrompt", async () => {
const throttleError = createMockError({
message: "Bedrock is unable to process your request",
status: 429,
@ -380,9 +379,9 @@ describe("AwsBedrockHandler Error Handling", () => {
mockGenerateText.mockRejectedValueOnce(throttleError)
// completePrompt does NOT have the throttle-rethrow path; it always uses handleAiSdkError
// completePrompt does NOT have the throttle-rethrow path; errors propagate unchanged
await expect(handler.completePrompt("test")).rejects.toThrow(
"Bedrock: Bedrock is unable to process your request",
"Bedrock is unable to process your request",
)
})
@ -396,7 +395,7 @@ describe("AwsBedrockHandler Error Handling", () => {
results.forEach((result) => {
expect(result.status).toBe("rejected")
if (result.status === "rejected") {
expect(result.reason.message).toContain("Bedrock:")
expect(result.reason.message).toBe("API failure")
}
})
})
@ -413,8 +412,7 @@ describe("AwsBedrockHandler Error Handling", () => {
await handler.completePrompt("test")
throw new Error("Expected error to be thrown")
} catch (error: any) {
expect(error.message).toContain("Bedrock:")
expect(error.message).toContain("Service unavailable")
expect(error.message).toBe("Service unavailable")
}
})
})
@ -479,7 +477,8 @@ describe("AwsBedrockHandler Error Handling", () => {
it("should handle non-Error objects thrown by generateText", async () => {
mockGenerateText.mockRejectedValueOnce("string error")
await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: string error")
// Non-Error values propagate as-is
await expect(handler.completePrompt("test")).rejects.toBe("string error")
})
it("should handle non-Error objects thrown by streamText", async () => {
@ -489,12 +488,12 @@ describe("AwsBedrockHandler Error Handling", () => {
const generator = handler.createMessage("system", [{ role: "user", content: "test" }])
// Non-Error values are not detected as throttling → handleAiSdkError path
// Non-Error values are not detected as throttling → propagate as-is
await expect(async () => {
for await (const _chunk of generator) {
// should throw
}
}).rejects.toThrow("Bedrock: string error")
}).rejects.toBe("string error")
})
it("should handle errors with unusual structure gracefully", async () => {
@ -505,9 +504,8 @@ describe("AwsBedrockHandler Error Handling", () => {
await handler.completePrompt("test")
throw new Error("Expected error to be thrown")
} catch (error: any) {
// handleAiSdkError wraps with "Bedrock: ..."
expect(error.message).toContain("Bedrock:")
expect(error.message).not.toContain("undefined")
// Errors propagate unchanged — the object's message property is preserved
expect(error.message).toBe("Error with unusual structure")
}
})

View file

@ -168,7 +168,7 @@ describe("LmStudioHandler", () => {
it("should handle API errors with handleAiSdkError", async () => {
mockGenerateText.mockRejectedValueOnce(new Error("Connection refused"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("LM Studio")
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Connection refused")
})
})

View file

@ -15,7 +15,6 @@ const {
mockCreateAnthropic,
mockModel,
mockMergeEnvironmentDetailsForMiniMax,
mockHandleAiSdkError,
} = vi.hoisted(() => {
const mockModel = vi.fn().mockReturnValue("mock-model-instance")
return {
@ -24,10 +23,6 @@ const {
mockCreateAnthropic: vi.fn().mockReturnValue(mockModel),
mockModel,
mockMergeEnvironmentDetailsForMiniMax: vi.fn((messages: RooMessage[]) => messages),
mockHandleAiSdkError: vi.fn((error: unknown, providerName: string) => {
const message = error instanceof Error ? error.message : String(error)
return new Error(`${providerName}: ${message}`)
}),
}
})
@ -44,13 +39,6 @@ vi.mock("../../transform/minimax-format", () => ({
mergeEnvironmentDetailsForMiniMax: mockMergeEnvironmentDetailsForMiniMax,
}))
vi.mock("../../transform/ai-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../transform/ai-sdk")>()
return {
...actual,
handleAiSdkError: mockHandleAiSdkError,
}
})
type HandlerOptions = Omit<Partial<ApiHandlerOptions>, "minimaxBaseUrl"> & {
minimaxBaseUrl?: string
@ -108,10 +96,6 @@ describe("MiniMaxHandler", () => {
vi.clearAllMocks()
mockCreateAnthropic.mockReturnValue(mockModel)
mockMergeEnvironmentDetailsForMiniMax.mockImplementation((inputMessages: RooMessage[]) => inputMessages)
mockHandleAiSdkError.mockImplementation((error: unknown, providerName: string) => {
const message = error instanceof Error ? error.message : String(error)
return new Error(`${providerName}: ${message}`)
})
})
describe("constructor", () => {
@ -359,8 +343,7 @@ describe("MiniMaxHandler", () => {
await expect(async () => {
await collectChunks(stream)
}).rejects.toThrow("MiniMax: API Error")
expect(mockHandleAiSdkError).toHaveBeenCalledWith(expect.any(Error), "MiniMax")
}).rejects.toThrow("API Error")
})
})

View file

@ -265,6 +265,33 @@ describe("NativeOllamaHandler", () => {
}).rejects.toThrow("Ollama service is not running")
})
it("propagates stream error when usage resolution fails after stream error", async () => {
async function* mockFullStream() {
yield { type: "error", error: new Error("upstream provider returned 500") }
}
mockStreamText.mockReturnValue({
fullStream: mockFullStream(),
usage: Promise.reject(new Error("No output generated")),
})
const stream = handler.createMessage("System", [{ role: "user" as const, content: "Test" }])
const results: any[] = []
await expect(async () => {
for await (const chunk of stream) {
results.push(chunk)
}
}).rejects.toThrow("upstream provider returned 500")
// The stream error should have been yielded before the throw
expect(results).toContainEqual({
type: "error",
error: "StreamError",
message: "upstream provider returned 500",
})
})
it("should handle model not found errors", async () => {
const error = new Error("Not found") as any
error.status = 404

View file

@ -109,7 +109,7 @@ describe("OpenAiCodexHandler.completePrompt", () => {
mockGenerateText.mockRejectedValue(new Error("API Error"))
await expect(handler.completePrompt("Say hello")).rejects.toThrow("OpenAI Codex")
await expect(handler.completePrompt("Say hello")).rejects.toThrow("API Error")
})
it("should throw when not authenticated", async () => {

View file

@ -307,7 +307,7 @@ describe("OpenAiNativeHandler", () => {
for await (const _chunk of stream) {
// drain
}
}).rejects.toThrow("OpenAI Native")
}).rejects.toThrow("API Error")
})
it("should pass system prompt to streamText", async () => {
@ -905,7 +905,7 @@ describe("OpenAiNativeHandler", () => {
it("should handle errors in completePrompt", async () => {
mockGenerateText.mockRejectedValue(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI Native")
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error")
})
it("should return empty string when no text in response", async () => {

View file

@ -544,17 +544,12 @@ describe("OpenRouterHandler", () => {
})
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
expect(chunks[0]).toEqual({
type: "error",
error: "OpenRouterError",
message: "OpenRouter API Error: API Error",
})
await expect(async () => {
for await (const _chunk of generator) {
// consume
}
}).rejects.toThrow("API Error")
// Verify telemetry was called
expect(mockCaptureException).toHaveBeenCalledTimes(1)
@ -594,6 +589,42 @@ describe("OpenRouterHandler", () => {
})
})
it("propagates stream error when usage resolution fails after stream error", async () => {
const handler = new OpenRouterHandler(mockOptions)
const mockFullStream = (async function* () {
yield { type: "error", error: new Error("upstream provider returned 500") }
})()
// Share one rejection so we don't create an unhandled-rejection for totalUsage
const usageRejection = Promise.reject(new Error("No output generated"))
// Prevent Node unhandled-rejection for the shared promise
usageRejection.catch(() => {})
mockStreamText.mockReturnValue({
fullStream: mockFullStream,
usage: usageRejection,
totalUsage: usageRejection,
providerMetadata: Promise.resolve(undefined),
})
const generator = handler.createMessage("test", [{ role: "user", content: "test" }])
const chunks: any[] = []
await expect(async () => {
for await (const chunk of generator) {
chunks.push(chunk)
}
}).rejects.toThrow("upstream provider returned 500")
// The stream error should have been yielded before the throw
expect(chunks).toContainEqual({
type: "error",
error: "StreamError",
message: "upstream provider returned 500",
})
})
it("passes tools to streamText when provided", async () => {
const handler = new OpenRouterHandler(mockOptions)
@ -779,9 +810,7 @@ describe("OpenRouterHandler", () => {
mockGenerateText.mockRejectedValue(new Error("API Error"))
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"OpenRouter completion error: API Error",
)
await expect(handler.completePrompt("test prompt")).rejects.toThrow("API Error")
// Verify telemetry was called
expect(mockCaptureException).toHaveBeenCalledTimes(1)
@ -799,9 +828,7 @@ describe("OpenRouterHandler", () => {
mockGenerateText.mockRejectedValue(new Error("Rate limit exceeded"))
await expect(handler.completePrompt("test prompt")).rejects.toThrow(
"OpenRouter completion error: Rate limit exceeded",
)
await expect(handler.completePrompt("test prompt")).rejects.toThrow("Rate limit exceeded")
// Verify telemetry was called
expect(mockCaptureException).toHaveBeenCalledTimes(1)

View file

@ -395,7 +395,7 @@ describe("RooHandler", () => {
it("should handle API errors", async () => {
mockGenerateText.mockRejectedValue(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Roo Code Cloud")
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API Error")
})
it("should handle empty response", async () => {

View file

@ -595,7 +595,7 @@ describe("SambaNovaHandler", () => {
for await (const _ of stream) {
// consume stream
}
}).rejects.toThrow("SambaNova: API Error")
}).rejects.toThrow("API Error")
})
it("should preserve status codes in error handling", async () => {
@ -621,7 +621,7 @@ describe("SambaNovaHandler", () => {
}
expect.fail("Should have thrown an error")
} catch (error: any) {
expect(error.message).toContain("SambaNova")
expect(error.message).toContain("Rate limit exceeded")
expect(error.status).toBe(429)
}
})

View file

@ -482,7 +482,7 @@ describe("VercelAiGatewayHandler", () => {
mockGenerateText.mockRejectedValue(new Error(errorMessage))
await expect(handler.completePrompt("Test")).rejects.toThrow("Vercel AI Gateway")
await expect(handler.completePrompt("Test")).rejects.toThrow("API error")
})
it("returns empty string when generateText returns empty text", async () => {

View file

@ -399,9 +399,8 @@ describe("XAIHandler", () => {
;(mockError as any).name = "AI_APICallError"
;(mockError as any).status = 500
async function* mockFullStream(): AsyncGenerator<never> {
// This yield is unreachable but needed to satisfy the require-yield lint rule
yield undefined as never
async function* mockFullStream(): AsyncGenerator<any> {
yield { type: "text-delta", text: "" }
throw mockError
}
@ -417,7 +416,7 @@ describe("XAIHandler", () => {
for await (const _ of stream) {
// consume stream
}
}).rejects.toThrow("xAI")
}).rejects.toThrow("API error")
})
})
@ -456,7 +455,7 @@ describe("XAIHandler", () => {
;(mockError as any).name = "AI_APICallError"
mockGenerateText.mockRejectedValue(mockError)
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("xAI")
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API error")
})
})

View file

@ -22,7 +22,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
@ -177,7 +176,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
TelemetryService.instance.captureException(
new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"),
)
throw handleAiSdkError(error, this.providerName)
throw error
}
}
@ -301,7 +300,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple
"completePrompt",
),
)
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -20,7 +20,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
@ -166,7 +165,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
TelemetryService.instance.captureException(
new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage"),
)
throw handleAiSdkError(error, this.providerName)
throw error
}
}
@ -279,7 +278,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
"completePrompt",
),
)
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -6,7 +6,7 @@ import { azureModels, azureDefaultModelInfo, type ModelInfo } from "@roo-code/ty
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -177,8 +177,7 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle
yield processUsage(usage, providerMetadata as Parameters<typeof processUsage>[1])
})
} catch (error) {
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
throw handleAiSdkError(error, "Azure AI Foundry")
throw error
}
}

View file

@ -6,7 +6,7 @@ import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -134,7 +134,7 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand
yield processUsage(usage)
})
} catch (error) {
throw handleAiSdkError(error, "Baseten")
throw error
}
}

View file

@ -28,7 +28,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
@ -333,8 +332,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
throw new Error("Throttling error occurred")
}
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
throw handleAiSdkError(error, this.providerName)
throw error
}
}
@ -458,8 +456,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "completePrompt")
TelemetryService.instance.captureException(apiError)
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -6,7 +6,7 @@ import { deepSeekModels, deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE,
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -156,7 +156,7 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
yield processUsage(usage, providerMetadata as Parameters<typeof processUsage>[1])
})
} catch (error) {
throw handleAiSdkError(error, "DeepSeek")
throw error
}
}

View file

@ -6,7 +6,7 @@ import { fireworksModels, fireworksDefaultModelId, type ModelInfo } from "@roo-c
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -156,7 +156,7 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
yield processUsage(usage, providerMetadata as Parameters<typeof processUsage>[1])
})
} catch (error) {
throw handleAiSdkError(error, "Fireworks")
throw error
}
}

View file

@ -17,7 +17,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -196,14 +195,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
yield* yieldResponseMessage(result)
} catch (error) {
throw handleAiSdkError(error, this.providerName, {
onError: (msg) => {
TelemetryService.instance.captureException(
new ApiProviderError(msg, this.providerName, modelId, "createMessage"),
)
},
formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }),
})
const errorMessage = error instanceof Error ? error.message : String(error)
TelemetryService.instance.captureException(
new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage"),
)
throw error
}
}
@ -354,14 +350,11 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
return text
} catch (error) {
throw handleAiSdkError(error, this.providerName, {
onError: (msg) => {
TelemetryService.instance.captureException(
new ApiProviderError(msg, this.providerName, modelId, "completePrompt"),
)
},
formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }),
})
const errorMessage = error instanceof Error ? error.message : String(error)
TelemetryService.instance.captureException(
new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt"),
)
throw error
}
}

View file

@ -13,7 +13,7 @@ import { type ModelInfo, openAiModelInfoSaneDefaults, LMSTUDIO_DEFAULT_TEMPERATU
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream } from "../transform/stream"
@ -90,7 +90,7 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo
yield processUsage(usage)
})
} catch (error) {
throw handleAiSdkError(error, "LM Studio")
throw error
}
}
@ -128,7 +128,7 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo
const { text } = await generateText(options)
return text
} catch (error) {
throw handleAiSdkError(error, "LM Studio")
throw error
}
}
}

View file

@ -12,7 +12,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -132,7 +131,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
yield* yieldResponseMessage(result)
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}
@ -205,7 +204,7 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
return text
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -12,7 +12,7 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -186,7 +186,7 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand
yield processUsage(usage)
})
} catch (error) {
throw handleAiSdkError(error, "Mistral")
throw error
}
}

View file

@ -10,7 +10,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -116,23 +115,34 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
const result = streamText(requestOptions)
try {
let lastStreamError: string | undefined
for await (const part of result.fullStream) {
for (const chunk of processAiSdkStreamPart(part)) {
if (chunk.type === "error") {
lastStreamError = chunk.message
}
yield chunk
}
}
const usage = await result.usage
if (usage) {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
yield {
type: "usage",
inputTokens,
outputTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
try {
const usage = await result.usage
if (usage) {
const inputTokens = usage.inputTokens || 0
const outputTokens = usage.outputTokens || 0
yield {
type: "usage",
inputTokens,
outputTokens,
totalInputTokens: inputTokens,
totalOutputTokens: outputTokens,
}
}
} catch (usageError) {
if (lastStreamError) {
throw new Error(lastStreamError)
}
throw usageError
}
yield* yieldResponseMessage(result)
@ -187,7 +197,7 @@ export class NativeOllamaHandler extends BaseProvider implements SingleCompletio
)
}
throw handleAiSdkError(error, "Ollama")
throw error
}
override isAiSdkProvider(): boolean {

View file

@ -19,7 +19,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { ApiStream } from "../transform/stream"
@ -302,7 +301,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
accessToken = refreshed
continue
}
throw handleAiSdkError(error, this.providerName)
throw error
}
}
}
@ -346,7 +345,7 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion
return text
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -12,7 +12,7 @@ import type { ModelInfo } from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -177,8 +177,7 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
yield processUsage(usage)
})
} catch (error) {
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
throw handleAiSdkError(error, this.config.providerName)
throw error
}
}

View file

@ -19,7 +19,7 @@ import {
import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -511,7 +511,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
}
})
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}
@ -547,7 +547,7 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio
return text
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -20,7 +20,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -239,7 +238,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
yield* yieldResponseMessage(result)
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}
@ -285,7 +284,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any)
}
} catch (error) {
throw handleAiSdkError(error, this.providerName)
throw error
}
}

View file

@ -197,35 +197,44 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
providerOptions,
})
let lastStreamError: string | undefined
for await (const part of result.fullStream) {
yield* processAiSdkStreamPart(part)
for (const chunk of processAiSdkStreamPart(part)) {
if (chunk.type === "error") {
lastStreamError = chunk.message
}
yield chunk
}
}
const providerMetadata =
(await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata)
try {
const providerMetadata =
(await result.providerMetadata) ?? (await (result as any).experimental_providerMetadata)
const usage = await result.usage
const totalUsage = await result.totalUsage
const usageChunk = this.normalizeUsage(
{
inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0,
outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0,
},
providerMetadata,
model.info,
)
yield usageChunk
const usage = await result.usage
const totalUsage = await result.totalUsage
const usageChunk = this.normalizeUsage(
{
inputTokens: totalUsage.inputTokens ?? usage.inputTokens ?? 0,
outputTokens: totalUsage.outputTokens ?? usage.outputTokens ?? 0,
},
providerMetadata,
model.info,
)
yield usageChunk
} catch (usageError) {
if (lastStreamError) {
throw new Error(lastStreamError)
}
throw usageError
}
yield* yieldResponseMessage(result)
} catch (error: any) {
const errorMessage = error instanceof Error ? error.message : String(error)
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
TelemetryService.instance.captureException(apiError)
yield {
type: "error",
error: "OpenRouterError",
message: `${this.providerName} API Error: ${errorMessage}`,
}
throw error
}
}
@ -322,7 +331,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
const errorMessage = error instanceof Error ? error.message : String(error)
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
TelemetryService.instance.captureException(apiError)
throw new Error(`${this.providerName} completion error: ${errorMessage}`)
throw error
}
}

View file

@ -7,7 +7,7 @@ import { type ModelInfo, type ModelRecord, requestyDefaultModelId, requestyDefau
import type { ApiHandlerOptions } from "../../shared/api"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyCacheBreakpoints, applyToolCacheOptions, applySystemPromptCaching } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -227,7 +227,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
yield processUsage(usage, info, providerMetadata as RequestyProviderMetadata)
})
} catch (error) {
throw handleAiSdkError(error, "Requesty")
throw error
}
}
@ -248,7 +248,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
return text
} catch (error) {
throw handleAiSdkError(error, "Requesty")
throw error
}
}

View file

@ -13,7 +13,6 @@ import { getModelParams } from "../transform/model-params"
import {
convertToolsForAiSdk,
processAiSdkStreamPart,
handleAiSdkError,
mapToolChoice,
yieldResponseMessage,
} from "../transform/ai-sdk"
@ -267,7 +266,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler
console.error(`[RooHandler] Error during message streaming: ${JSON.stringify(errorContext)}`)
throw handleAiSdkError(error, "Roo Code Cloud")
throw error
}
}
@ -283,7 +282,7 @@ export class RooHandler extends BaseProvider implements SingleCompletionHandler
})
return result.text
} catch (error) {
throw handleAiSdkError(error, "Roo Code Cloud")
throw error
}
}

View file

@ -10,7 +10,6 @@ import {
convertToolsForAiSdk,
consumeAiSdkStream,
mapToolChoice,
handleAiSdkError,
flattenAiSdkMessagesToStringContent,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -164,7 +163,7 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa
yield processUsage(usage, providerMetadata as Parameters<typeof processUsage>[1])
})
} catch (error) {
throw handleAiSdkError(error, "SambaNova")
throw error
}
}

View file

@ -1,4 +1,4 @@
import { handleProviderError, handleOpenAIError } from "../error-handler"
import { handleProviderError } from "../error-handler"
describe("handleProviderError", () => {
const providerName = "TestProvider"
@ -259,25 +259,3 @@ describe("handleProviderError", () => {
})
})
describe("handleOpenAIError (backward compatibility)", () => {
it("should be an alias for handleProviderError with completion prefix", () => {
const error = new Error("API failed") as any
error.status = 500
const result = handleOpenAIError(error, "OpenAI")
expect(result).toBeInstanceOf(Error)
expect(result.message).toContain("OpenAI completion error")
expect((result as any).status).toBe(500)
})
it("should preserve backward compatibility for existing callers", () => {
const error = new Error("Authentication failed") as any
error.status = 401
const result = handleOpenAIError(error, "Roo Code Cloud")
expect(result.message).toBe("Roo Code Cloud completion error: Authentication failed")
expect((result as any).status).toBe(401)
})
})

View file

@ -105,10 +105,3 @@ export function handleProviderError(
return wrapped
}
/**
* Specialized handler for OpenAI-compatible providers
* Re-exports with OpenAI-specific defaults for backward compatibility
*/
export function handleOpenAIError(error: unknown, providerName: string): Error {
return handleProviderError(error, providerName, { messagePrefix: "completion" })
}

View file

@ -15,7 +15,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -181,7 +180,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple
yield* yieldResponseMessage(result)
} catch (error) {
throw handleAiSdkError(error, "Vercel AI Gateway")
throw error
}
}
@ -203,7 +202,7 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple
return text
} catch (error) {
throw handleAiSdkError(error, "Vercel AI Gateway")
throw error
}
}

View file

@ -17,7 +17,6 @@ import {
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
yieldResponseMessage,
} from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
@ -190,14 +189,11 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
yield* yieldResponseMessage(result)
} catch (error) {
throw handleAiSdkError(error, this.providerName, {
onError: (msg) => {
TelemetryService.instance.captureException(
new ApiProviderError(msg, this.providerName, modelId, "createMessage"),
)
},
formatMessage: (msg) => t("common:errors.gemini.generate_stream", { error: msg }),
})
const errorMessage = error instanceof Error ? error.message : String(error)
TelemetryService.instance.captureException(
new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage"),
)
throw error
}
}
@ -348,14 +344,11 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl
return text
} catch (error) {
throw handleAiSdkError(error, this.providerName, {
onError: (msg) => {
TelemetryService.instance.captureException(
new ApiProviderError(msg, this.providerName, modelId, "completePrompt"),
)
},
formatMessage: (msg) => t("common:errors.gemini.generate_complete_prompt", { error: msg }),
})
const errorMessage = error instanceof Error ? error.message : String(error)
TelemetryService.instance.captureException(
new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt"),
)
throw error
}
}

View file

@ -6,7 +6,7 @@ import { type XAIModelId, xaiDefaultModelId, xaiModels, type ModelInfo } from "@
import type { ApiHandlerOptions } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -165,7 +165,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
yield processUsage(usage, providerMetadata as Parameters<typeof processUsage>[1])
})
} catch (error) {
throw handleAiSdkError(error, "xAI")
throw error
}
}
@ -187,7 +187,7 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
return text
} catch (error) {
throw handleAiSdkError(error, "xAI")
throw error
}
}

View file

@ -14,7 +14,7 @@ import {
import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice, handleAiSdkError } from "../transform/ai-sdk"
import { convertToolsForAiSdk, consumeAiSdkStream, mapToolChoice } from "../transform/ai-sdk"
import { applyToolCacheOptions } from "../transform/cache-breakpoints"
import { ApiStream } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
@ -126,7 +126,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler
try {
yield* consumeAiSdkStream(result)
} catch (error) {
throw handleAiSdkError(error, "Z.ai")
throw error
}
}
@ -147,7 +147,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler
return text
} catch (error) {
throw handleAiSdkError(error, "Z.ai")
throw error
}
}

View file

@ -1047,6 +1047,65 @@ describe("Error extraction utilities", () => {
const result = extractMessageFromResponseBody(body)
expect(result).toBe("top level error")
})
it("extracts message from Anthropic-style error with error.type", () => {
const body = JSON.stringify({
type: "error",
error: {
type: "overloaded_error",
message: "Overloaded",
},
})
const result = extractMessageFromResponseBody(body)
expect(result).toBeDefined()
expect(result).toContain("Overloaded")
})
it("extracts message from OpenRouter + Anthropic nested error format in metadata.raw", () => {
const body = JSON.stringify({
error: {
message: "Provider returned error",
code: 400,
metadata: {
raw: JSON.stringify({
type: "error",
error: {
type: "invalid_request_error",
message:
"A maximum of 4 blocks with cache_control may be provided. Found 5.",
},
}),
provider_name: "Anthropic",
},
},
})
const result = extractMessageFromResponseBody(body)
expect(result).toBe(
"[Anthropic] [invalid_request_error] A maximum of 4 blocks with cache_control may be provided. Found 5.",
)
})
it("extracts message from OpenRouter + Anthropic nested error format without provider_name", () => {
const body = JSON.stringify({
error: {
message: "Provider returned error",
code: 400,
metadata: {
raw: JSON.stringify({
type: "error",
error: {
type: "overloaded_error",
message: "Overloaded",
},
}),
},
},
})
const result = extractMessageFromResponseBody(body)
expect(result).toBe("[overloaded_error] Overloaded")
})
})
describe("extractAiSdkErrorMessage", () => {
@ -1141,5 +1200,94 @@ describe("Error extraction utilities", () => {
const result = extractAiSdkErrorMessage(error)
expect(result).toContain("some error")
})
it("should extract message from deeply nested NoOutputGeneratedError → RetryError → APICallError chain", () => {
const error = {
name: "AI_NoOutputGeneratedError",
message: "No output generated. Check the stream for errors.",
cause: {
name: "AI_RetryError",
message: "Failed after 3 attempts.",
lastError: {
name: "AI_APICallError",
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({
error: {
message: "Your credit balance is too low.",
type: "insufficient_quota",
code: "quota_exceeded",
},
}),
},
errors: [],
},
}
const result = extractAiSdkErrorMessage(error)
expect(result).toContain("quota_exceeded")
expect(result).toContain("Your credit balance is too low.")
expect(result).toContain("400")
expect(result).not.toContain("No output generated")
expect(result).not.toContain("Bad Request")
})
it("should extract message from RetryError with nested cause chain", () => {
const error = {
name: "AI_RetryError",
message: "Failed after 2 attempts.",
lastError: {
name: "AI_APICallError",
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({
type: "error",
error: {
type: "overloaded_error",
message: "Overloaded",
},
}),
},
errors: [],
}
const result = extractAiSdkErrorMessage(error)
expect(result).toContain("Overloaded")
expect(result).toContain("400")
})
it("should handle triple-nested error chain via .errors[] array", () => {
const error = {
name: "AI_NoOutputGeneratedError",
message: "No output generated.",
cause: {
name: "AI_RetryError",
message: "Failed after 3 attempts.",
lastError: {
name: "AI_APICallError",
message: "Server Error",
statusCode: 500,
responseBody: "", // empty — not useful
},
errors: [
{
name: "AI_APICallError",
message: "Bad Request",
statusCode: 400,
responseBody: JSON.stringify({
error: { message: "Invalid model ID", code: "invalid_model" },
}),
},
{
name: "AI_APICallError",
message: "Server Error",
statusCode: 500,
responseBody: "",
},
],
},
}
const result = extractAiSdkErrorMessage(error)
expect(result).toContain("Invalid model ID")
expect(result).toContain("400")
})
})
})

View file

@ -181,7 +181,7 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator<Api
yield {
type: "error",
error: "StreamError",
message: part.error instanceof Error ? part.error.message : String(part.error),
message: extractAiSdkErrorMessage(part.error),
}
break
@ -363,11 +363,25 @@ export function extractMessageFromResponseBody(responseBody: string): string | u
if (typeof rawParsed === "object" && rawParsed !== null) {
const rawObj = rawParsed as Record<string, unknown>
if (typeof rawObj.message === "string" && rawObj.message) {
const providerName =
typeof metadata.provider_name === "string" ? metadata.provider_name : undefined
const prefix = providerName ? `[${providerName}] ` : ""
return `${prefix}${rawObj.message}`
}
const providerName =
typeof metadata.provider_name === "string" ? metadata.provider_name : undefined
const prefix = providerName ? `[${providerName}] ` : ""
return `${prefix}${rawObj.message}`
}
// Anthropic format: {"type":"error","error":{"type":"invalid_request_error","message":"..."}}
if (typeof rawObj.error === "object" && rawObj.error !== null) {
const innerError = rawObj.error as Record<string, unknown>
if (typeof innerError.message === "string" && innerError.message) {
const providerName =
typeof metadata.provider_name === "string"
? metadata.provider_name
: undefined
const prefix = providerName ? `[${providerName}] ` : ""
const typePrefix =
typeof innerError.type === "string" ? `[${innerError.type}] ` : ""
return `${prefix}${typePrefix}${innerError.message}`
}
}
}
} catch {
// raw is not valid JSON — fall through to other patterns
@ -382,6 +396,11 @@ export function extractMessageFromResponseBody(responseBody: string): string | u
if (typeof errorObj.code === "number") {
return `[${errorObj.code}] ${errorObj.message}`
}
// Anthropic format: error.type instead of error.code
// e.g. {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}
if (typeof errorObj.type === "string" && errorObj.type) {
return `[${errorObj.type}] ${errorObj.message}`
}
return errorObj.message
}
}
@ -403,6 +422,48 @@ export function extractMessageFromResponseBody(responseBody: string): string | u
}
}
/**
* Recursively traverses an error chain to find the deepest APICallError
* with a non-empty responseBody. Checks .cause, .lastError, and .errors[].
*/
function findDeepestApiCallError(error: unknown, maxDepth = 10): Record<string, unknown> | undefined {
if (maxDepth <= 0 || typeof error !== "object" || error === null) {
return undefined
}
const obj = error as Record<string, unknown>
// Recurse children FIRST so we find the DEEPEST match
// Check .cause
const fromCause = findDeepestApiCallError(obj.cause, maxDepth - 1)
if (fromCause) {
return fromCause
}
// Check .lastError
const fromLastError = findDeepestApiCallError(obj.lastError, maxDepth - 1)
if (fromLastError) {
return fromLastError
}
// Check .errors[] array
if (Array.isArray(obj.errors)) {
for (const element of obj.errors) {
const fromElement = findDeepestApiCallError(element, maxDepth - 1)
if (fromElement) {
return fromElement
}
}
}
// Then check self
if (obj.name === "AI_APICallError" && typeof obj.responseBody === "string" && obj.responseBody.length > 0) {
return obj
}
return undefined
}
/**
* Extract a user-friendly error message from AI SDK errors.
* The AI SDK wraps errors in types like AI_RetryError and AI_APICallError
@ -422,6 +483,22 @@ export function extractAiSdkErrorMessage(error: unknown): string {
const errorObj = error as Record<string, unknown>
// First, try to find the deepest APICallError with a responseBody in the error chain.
// This handles arbitrarily nested chains like NoOutput → Retry → APICallError.
const deepestApiError = findDeepestApiCallError(error)
if (deepestApiError) {
const responseBody = deepestApiError.responseBody as string
const extracted = extractMessageFromResponseBody(responseBody)
const statusCode = getStatusCode(deepestApiError)
if (extracted) {
return statusCode ? `API Error (${statusCode}): ${extracted}` : `API Error: ${extracted}`
}
// Fall back to raw responseBody
return statusCode
? `API Error (${statusCode}): ${responseBody}`
: `API Error: ${responseBody}`
}
// AI_RetryError has a lastError property with the actual error
if (errorObj.name === "AI_RetryError") {
const retryCount = Array.isArray(errorObj.errors) ? errorObj.errors.length : 0

View file

@ -1,13 +1,50 @@
import { APICallError, RetryError } from "ai"
import { APIError } from "openai"
export function checkContextWindowExceededError(error: unknown): boolean {
return (
checkIsAiSdkContextWindowError(error) ||
checkIsOpenAIContextWindowError(error) ||
checkIsOpenRouterContextWindowError(error) ||
checkIsAnthropicContextWindowError(error)
)
}
function checkIsAiSdkContextWindowError(error: unknown): boolean {
try {
// Unwrap RetryError to get the underlying APICallError
let apiError: unknown = error
if (RetryError.isInstance(error)) {
apiError = error.lastError
}
if (!APICallError.isInstance(apiError)) {
return false
}
if (apiError.statusCode !== 400) {
return false
}
// Check message and responseBody for context window indicators
const textsToCheck = [apiError.message, apiError.responseBody].filter((t): t is string => typeof t === "string")
const contextWindowPatterns = [
/\bcontext\s*(?:length|window)\b/i,
/\btoken\s*limit\b/i,
/maximum\s*(?:context\s*)?(?:length|tokens)/i,
/prompt\s*is\s*too\s*long/i,
/input\s*is\s*too\s*long/i,
/too\s*many\s*tokens/i,
/content\s*size\s*exceeds/i,
/request\s*too\s*large/i,
]
return textsToCheck.some((text) => contextWindowPatterns.some((pattern) => pattern.test(text)))
} catch {
return false
}
}
function checkIsOpenRouterContextWindowError(error: unknown): boolean {
try {
if (!error || typeof error !== "object") {

View file

@ -61,7 +61,9 @@ import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
// api
import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api"
import type { AssistantModelMessage } from "ai"
import { APICallError, RetryError } from "ai"
import { ApiStream, GroundingSource } from "../../api/transform/stream"
import { extractAiSdkErrorMessage } from "../../api/transform/ai-sdk"
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
import { UNIVERSAL_CACHE_OPTIONS } from "../../api/transform/cache-breakpoints"
@ -3354,7 +3356,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// Determine cancellation reason
const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed"
const rawErrorMessage = error.message ?? JSON.stringify(serializeError(error), null, 2)
const rawErrorMessage = extractAiSdkErrorMessage(error)
// Check auto-retry state BEFORE abortStream so we can suppress the error
// message on the api_req_started row when backoffAndAnnounce will display it instead.
@ -4536,7 +4538,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
} else {
const { response } = await this.ask(
"api_req_failed",
error.message ?? JSON.stringify(serializeError(error), null, 2),
extractAiSdkErrorMessage(error),
)
if (response !== "yesButtonClicked") {
@ -4583,35 +4585,66 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
rateLimitDelay = Math.ceil(Math.min(rateLimit, Math.max(0, rateLimit * 1000 - elapsed) / 1000))
}
// Prefer RetryInfo on 429 if present
if (error?.status === 429) {
const retryInfo = error?.errorDetails?.find(
(d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
)
const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/)
if (match) {
exponentialDelay = Number(match[1]) + 1
// Extract status code from AI SDK errors or legacy error shapes
const statusCode = APICallError.isInstance(error)
? error.statusCode
: RetryError.isInstance(error) && APICallError.isInstance(error.lastError)
? error.lastError.statusCode
: (error as any)?.status
// Prefer RetryInfo on 429 if present
if (statusCode === 429) {
// Try direct errorDetails (legacy Vertex), then try parsing from responseBody
let retryDelaySec: number | undefined
const errorDetails = (error as any)?.errorDetails
if (errorDetails) {
const retryInfo = errorDetails.find(
(d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
)
const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/)
if (match) {
retryDelaySec = Number(match[1]) + 1
}
}
// Also try extracting from APICallError responseBody for Vertex errors
if (!retryDelaySec) {
const responseBody = APICallError.isInstance(error)
? error.responseBody
: RetryError.isInstance(error) && APICallError.isInstance(error.lastError)
? error.lastError.responseBody
: undefined
if (responseBody) {
try {
const parsed = JSON.parse(responseBody)
const retryInfo = parsed?.error?.details?.find(
(d: any) => d["@type"] === "type.googleapis.com/google.rpc.RetryInfo",
)
const match = retryInfo?.retryDelay?.match?.(/^(\d+)s$/)
if (match) {
retryDelaySec = Number(match[1]) + 1
}
} catch {
// responseBody not parseable, skip
}
}
}
if (retryDelaySec) {
exponentialDelay = retryDelaySec
}
}
const finalDelay = Math.max(exponentialDelay, rateLimitDelay)
if (finalDelay <= 0) {
return
}
// Build header text; fall back to error message if none provided
let headerText: string
if (statusCode) {
headerText = `${statusCode}\n${extractAiSdkErrorMessage(error)}`
} else {
headerText = extractAiSdkErrorMessage(error)
}
}
const finalDelay = Math.max(exponentialDelay, rateLimitDelay)
if (finalDelay <= 0) {
return
}
// Build header text; fall back to error message if none provided
let headerText
if (error.status) {
// Include both status code (for ChatRow parsing) and detailed message (for error details)
// Format: "<status>\n<message>" allows ChatRow to extract status via parseInt(text.substring(0,3))
// while preserving the full error message in errorDetails for debugging
const errorMessage = error?.message || "Unknown error"
headerText = `${error.status}\n${errorMessage}`
} else if (error?.message) {
headerText = error.message
} else {
headerText = "Unknown error"
}
headerText = headerText ? `${headerText}\n` : ""