From 0bd02aeb46ab7f06094025eb0d777610bbb22a0c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 13 Feb 2026 12:53:17 -0700 Subject: [PATCH] refactor: stop wrapping AI SDK errors, surface real provider error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/api/providers/__tests__/azure.spec.ts | 2 +- src/api/providers/__tests__/baseten.spec.ts | 4 +- .../__tests__/bedrock-error-handling.spec.ts | 44 +++--- src/api/providers/__tests__/lmstudio.spec.ts | 2 +- src/api/providers/__tests__/minimax.spec.ts | 19 +-- .../providers/__tests__/native-ollama.spec.ts | 27 ++++ .../providers/__tests__/openai-codex.spec.ts | 2 +- .../providers/__tests__/openai-native.spec.ts | 4 +- .../providers/__tests__/openrouter.spec.ts | 59 +++++-- src/api/providers/__tests__/roo.spec.ts | 2 +- src/api/providers/__tests__/sambanova.spec.ts | 4 +- .../__tests__/vercel-ai-gateway.spec.ts | 2 +- src/api/providers/__tests__/xai.spec.ts | 9 +- src/api/providers/anthropic-vertex.ts | 5 +- src/api/providers/anthropic.ts | 5 +- src/api/providers/azure.ts | 5 +- src/api/providers/baseten.ts | 4 +- src/api/providers/bedrock.ts | 7 +- src/api/providers/deepseek.ts | 4 +- src/api/providers/fireworks.ts | 4 +- src/api/providers/gemini.ts | 27 ++-- src/api/providers/lm-studio.ts | 6 +- src/api/providers/minimax.ts | 5 +- src/api/providers/mistral.ts | 4 +- src/api/providers/native-ollama.ts | 34 ++-- src/api/providers/openai-codex.ts | 5 +- src/api/providers/openai-compatible.ts | 5 +- src/api/providers/openai-native.ts | 6 +- src/api/providers/openai.ts | 5 +- src/api/providers/openrouter.ts | 49 +++--- src/api/providers/requesty.ts | 6 +- src/api/providers/roo.ts | 5 +- src/api/providers/sambanova.ts | 3 +- .../utils/__tests__/error-handler.spec.ts | 24 +-- src/api/providers/utils/error-handler.ts | 7 - src/api/providers/vercel-ai-gateway.ts | 5 +- src/api/providers/vertex.ts | 27 ++-- src/api/providers/xai.ts | 6 +- src/api/providers/zai.ts | 6 +- src/api/transform/__tests__/ai-sdk.spec.ts | 148 ++++++++++++++++++ src/api/transform/ai-sdk.ts | 89 ++++++++++- .../context-error-handling.ts | 37 +++++ src/core/task/Task.ts | 93 +++++++---- 43 files changed, 554 insertions(+), 262 deletions(-) diff --git a/src/api/providers/__tests__/azure.spec.ts b/src/api/providers/__tests__/azure.spec.ts index e95d7de46a..f16f6d14e6 100644 --- a/src/api/providers/__tests__/azure.spec.ts +++ b/src/api/providers/__tests__/azure.spec.ts @@ -335,7 +335,7 @@ describe("AzureHandler", () => { for await (const chunk of stream) { chunks.push(chunk) } - }).rejects.toThrow("Azure AI Foundry") + }).rejects.toThrow("API Error") }) }) diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts index 43b21f28dc..efbe428d6b 100644 --- a/src/api/providers/__tests__/baseten.spec.ts +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -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) } }) diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index d217984c8d..7e61de3d6a 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -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") } }) diff --git a/src/api/providers/__tests__/lmstudio.spec.ts b/src/api/providers/__tests__/lmstudio.spec.ts index aaded984db..337449006f 100644 --- a/src/api/providers/__tests__/lmstudio.spec.ts +++ b/src/api/providers/__tests__/lmstudio.spec.ts @@ -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") }) }) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 84ecce0f24..cbd740d035 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -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() - return { - ...actual, - handleAiSdkError: mockHandleAiSdkError, - } -}) type HandlerOptions = Omit, "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") }) }) diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index e87e8e6f4e..3d0d5bf207 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -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 diff --git a/src/api/providers/__tests__/openai-codex.spec.ts b/src/api/providers/__tests__/openai-codex.spec.ts index 8eb4fcc265..db0483ee5c 100644 --- a/src/api/providers/__tests__/openai-codex.spec.ts +++ b/src/api/providers/__tests__/openai-codex.spec.ts @@ -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 () => { diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index 568ed9ce97..dd43d9ea71 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -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 () => { diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 763d0ef606..847cd3c9e6 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -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) diff --git a/src/api/providers/__tests__/roo.spec.ts b/src/api/providers/__tests__/roo.spec.ts index 3e8278afb6..31c060d292 100644 --- a/src/api/providers/__tests__/roo.spec.ts +++ b/src/api/providers/__tests__/roo.spec.ts @@ -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 () => { diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 6c9e993192..447738a6bf 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -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) } }) diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 1864a6a4b5..7fd6d5d0bb 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -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 () => { diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 10c3181dfb..9e1094a945 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -399,9 +399,8 @@ describe("XAIHandler", () => { ;(mockError as any).name = "AI_APICallError" ;(mockError as any).status = 500 - async function* mockFullStream(): AsyncGenerator { - // This yield is unreachable but needed to satisfy the require-yield lint rule - yield undefined as never + async function* mockFullStream(): AsyncGenerator { + 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") }) }) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 33e4fd3ab4..e97c9f47dc 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -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 } } diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index 8827eae496..8bc923961a 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -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 } } diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index 374a6e1697..d1767c7745 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -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[1]) }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) - throw handleAiSdkError(error, "Azure AI Foundry") + throw error } } diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 69b261fe74..a6592ab98c 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -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 } } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 4d9ac15003..4f75bbacac 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -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 } } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index f9f49e4ec4..fa854b6949 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -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[1]) }) } catch (error) { - throw handleAiSdkError(error, "DeepSeek") + throw error } } diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index 603da3bd81..146633054c 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -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[1]) }) } catch (error) { - throw handleAiSdkError(error, "Fireworks") + throw error } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index fed6375cf1..8679f19b43 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -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 } } diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index 905f1f4da5..55ab8013ac 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -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 } } } diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index ec26364ee5..b8f291a329 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -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 } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index c91d91d0f9..e7273d28c7 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -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 } } diff --git a/src/api/providers/native-ollama.ts b/src/api/providers/native-ollama.ts index 697c445a65..b8b29865bf 100644 --- a/src/api/providers/native-ollama.ts +++ b/src/api/providers/native-ollama.ts @@ -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 { diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 32b145e3e7..cdee2d65ed 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -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 } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 7c378f7bc6..995b33a223 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -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 } } diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index 09fb9939e1..e384f38fa3 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -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 } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 9879b1ce13..b02e365f3b 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -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 } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 1495a8246b..16bfc331b2 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -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 } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 85895c9422..bf730d00ec 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -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 } } diff --git a/src/api/providers/roo.ts b/src/api/providers/roo.ts index 96a4d31373..6faf59ee9a 100644 --- a/src/api/providers/roo.ts +++ b/src/api/providers/roo.ts @@ -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 } } diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index 6e71a558a1..bf475239a0 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -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[1]) }) } catch (error) { - throw handleAiSdkError(error, "SambaNova") + throw error } } diff --git a/src/api/providers/utils/__tests__/error-handler.spec.ts b/src/api/providers/utils/__tests__/error-handler.spec.ts index 54971134df..5f20b6b966 100644 --- a/src/api/providers/utils/__tests__/error-handler.spec.ts +++ b/src/api/providers/utils/__tests__/error-handler.spec.ts @@ -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) - }) -}) diff --git a/src/api/providers/utils/error-handler.ts b/src/api/providers/utils/error-handler.ts index 2c55b96f9c..2352b4b79a 100644 --- a/src/api/providers/utils/error-handler.ts +++ b/src/api/providers/utils/error-handler.ts @@ -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" }) -} diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index 56cbf68914..b17d981b95 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -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 } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 669044b3ec..57936b9f3e 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -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 } } diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 3d3df048a5..6d2c117448 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -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[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 } } diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index af1f8cbd7b..bbdd003a0e 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -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 } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 6deed8712f..cb9c80c10d 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -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") + }) }) }) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index b527f5afa9..13e727e3c0 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -181,7 +181,7 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator 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 + 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 | undefined { + if (maxDepth <= 0 || typeof error !== "object" || error === null) { + return undefined + } + + const obj = error as Record + + // 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 + // 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 diff --git a/src/core/context/context-management/context-error-handling.ts b/src/core/context/context-management/context-error-handling.ts index 6cfe993f95..9d94f867b5 100644 --- a/src/core/context/context-management/context-error-handling.ts +++ b/src/core/context/context-management/context-error-handling.ts @@ -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") { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7e94208785..8a564e56b3 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -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 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 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 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: "\n" 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` : ""