Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit 5d85ec2

Browse files
committed
fix(api): add proper error handling for AI SDK providers
- Added extractAiSdkErrorMessage utility to extract user-friendly error messages from AI SDK errors (AI_RetryError, AI_APICallError) - Added handleAiSdkError utility to wrap errors with provider name and preserve status codes for retry logic - Updated all AI SDK providers (Fireworks, Groq, DeepSeek, Cerebras, OpenAI-compatible) with try/catch error handling - Added comprehensive tests for error handling utilities This ensures errors like 'AI_RetryError: Failed after 3 attempts. Last error: Too Many Requests' are properly surfaced in the UI instead of showing 'No output generated. Check the stream for errors.'
1 parent b6c4411 commit 5d85ec2

7 files changed

Lines changed: 274 additions & 50 deletions

File tree

src/api/providers/cerebras.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
convertToolsForAiSdk,
1212
processAiSdkStreamPart,
1313
mapToolChoice,
14+
handleAiSdkError,
1415
} from "../transform/ai-sdk"
1516
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1617
import { getModelParams } from "../transform/model-params"
@@ -120,17 +121,22 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
120121
// Use streamText for streaming responses
121122
const result = streamText(requestOptions)
122123

123-
// Process the full stream to get all events including reasoning
124-
for await (const part of result.fullStream) {
125-
for (const chunk of processAiSdkStreamPart(part)) {
126-
yield chunk
124+
try {
125+
// Process the full stream to get all events including reasoning
126+
for await (const part of result.fullStream) {
127+
for (const chunk of processAiSdkStreamPart(part)) {
128+
yield chunk
129+
}
127130
}
128-
}
129131

130-
// Yield usage metrics at the end
131-
const usage = await result.usage
132-
if (usage) {
133-
yield this.processUsageMetrics(usage)
132+
// Yield usage metrics at the end
133+
const usage = await result.usage
134+
if (usage) {
135+
yield this.processUsageMetrics(usage)
136+
}
137+
} catch (error) {
138+
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
139+
throw handleAiSdkError(error, "Cerebras")
134140
}
135141
}
136142

src/api/providers/deepseek.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
convertToolsForAiSdk,
1212
processAiSdkStreamPart,
1313
mapToolChoice,
14+
handleAiSdkError,
1415
} from "../transform/ai-sdk"
1516
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1617
import { getModelParams } from "../transform/model-params"
@@ -129,18 +130,23 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan
129130
// Use streamText for streaming responses
130131
const result = streamText(requestOptions)
131132

132-
// Process the full stream to get all events including reasoning
133-
for await (const part of result.fullStream) {
134-
for (const chunk of processAiSdkStreamPart(part)) {
135-
yield chunk
133+
try {
134+
// Process the full stream to get all events including reasoning
135+
for await (const part of result.fullStream) {
136+
for (const chunk of processAiSdkStreamPart(part)) {
137+
yield chunk
138+
}
136139
}
137-
}
138140

139-
// Yield usage metrics at the end, including cache metrics from providerMetadata
140-
const usage = await result.usage
141-
const providerMetadata = await result.providerMetadata
142-
if (usage) {
143-
yield this.processUsageMetrics(usage, providerMetadata as any)
141+
// Yield usage metrics at the end, including cache metrics from providerMetadata
142+
const usage = await result.usage
143+
const providerMetadata = await result.providerMetadata
144+
if (usage) {
145+
yield this.processUsageMetrics(usage, providerMetadata as any)
146+
}
147+
} catch (error) {
148+
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
149+
throw handleAiSdkError(error, "DeepSeek")
144150
}
145151
}
146152

src/api/providers/fireworks.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
convertToolsForAiSdk,
1212
processAiSdkStreamPart,
1313
mapToolChoice,
14+
handleAiSdkError,
1415
} from "../transform/ai-sdk"
1516
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1617
import { getModelParams } from "../transform/model-params"
@@ -135,18 +136,23 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa
135136
// Use streamText for streaming responses
136137
const result = streamText(requestOptions)
137138

138-
// Process the full stream to get all events including reasoning
139-
for await (const part of result.fullStream) {
140-
for (const chunk of processAiSdkStreamPart(part)) {
141-
yield chunk
139+
try {
140+
// Process the full stream to get all events including reasoning
141+
for await (const part of result.fullStream) {
142+
for (const chunk of processAiSdkStreamPart(part)) {
143+
yield chunk
144+
}
142145
}
143-
}
144146

145-
// Yield usage metrics at the end, including cache metrics from providerMetadata
146-
const usage = await result.usage
147-
const providerMetadata = await result.providerMetadata
148-
if (usage) {
149-
yield this.processUsageMetrics(usage, providerMetadata as any)
147+
// Yield usage metrics at the end, including cache metrics from providerMetadata
148+
const usage = await result.usage
149+
const providerMetadata = await result.providerMetadata
150+
if (usage) {
151+
yield this.processUsageMetrics(usage, providerMetadata as any)
152+
}
153+
} catch (error) {
154+
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
155+
throw handleAiSdkError(error, "Fireworks")
150156
}
151157
}
152158

src/api/providers/groq.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
convertToolsForAiSdk,
1212
processAiSdkStreamPart,
1313
mapToolChoice,
14+
handleAiSdkError,
1415
} from "../transform/ai-sdk"
1516
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1617
import { getModelParams } from "../transform/model-params"
@@ -137,18 +138,23 @@ export class GroqHandler extends BaseProvider implements SingleCompletionHandler
137138
// Use streamText for streaming responses
138139
const result = streamText(requestOptions)
139140

140-
// Process the full stream to get all events including reasoning
141-
for await (const part of result.fullStream) {
142-
for (const chunk of processAiSdkStreamPart(part)) {
143-
yield chunk
141+
try {
142+
// Process the full stream to get all events including reasoning
143+
for await (const part of result.fullStream) {
144+
for (const chunk of processAiSdkStreamPart(part)) {
145+
yield chunk
146+
}
144147
}
145-
}
146148

147-
// Yield usage metrics at the end, including cache metrics from providerMetadata
148-
const usage = await result.usage
149-
const providerMetadata = await result.providerMetadata
150-
if (usage) {
151-
yield this.processUsageMetrics(usage, providerMetadata as any)
149+
// Yield usage metrics at the end, including cache metrics from providerMetadata
150+
const usage = await result.usage
151+
const providerMetadata = await result.providerMetadata
152+
if (usage) {
153+
yield this.processUsageMetrics(usage, providerMetadata as any)
154+
}
155+
} catch (error) {
156+
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
157+
throw handleAiSdkError(error, "Groq")
152158
}
153159
}
154160

src/api/providers/openai-compatible.ts

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
convertToolsForAiSdk,
1818
processAiSdkStreamPart,
1919
mapToolChoice,
20+
handleAiSdkError,
2021
} from "../transform/ai-sdk"
2122
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
2223

@@ -150,18 +151,23 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si
150151
// Use streamText for streaming responses
151152
const result = streamText(requestOptions)
152153

153-
// Process the full stream to get all events
154-
for await (const part of result.fullStream) {
155-
// Use the processAiSdkStreamPart utility to convert stream parts
156-
for (const chunk of processAiSdkStreamPart(part)) {
157-
yield chunk
154+
try {
155+
// Process the full stream to get all events
156+
for await (const part of result.fullStream) {
157+
// Use the processAiSdkStreamPart utility to convert stream parts
158+
for (const chunk of processAiSdkStreamPart(part)) {
159+
yield chunk
160+
}
158161
}
159-
}
160162

161-
// Yield usage metrics at the end
162-
const usage = await result.usage
163-
if (usage) {
164-
yield this.processUsageMetrics(usage)
163+
// Yield usage metrics at the end
164+
const usage = await result.usage
165+
if (usage) {
166+
yield this.processUsageMetrics(usage)
167+
}
168+
} catch (error) {
169+
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
170+
throw handleAiSdkError(error, this.config.providerName)
165171
}
166172
}
167173

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 114 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import OpenAI from "openai"
3-
import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, mapToolChoice } from "../ai-sdk"
3+
import {
4+
convertToAiSdkMessages,
5+
convertToolsForAiSdk,
6+
processAiSdkStreamPart,
7+
mapToolChoice,
8+
extractAiSdkErrorMessage,
9+
handleAiSdkError,
10+
} from "../ai-sdk"
411

512
vitest.mock("ai", () => ({
613
tool: vitest.fn((t) => t),
@@ -531,4 +538,110 @@ describe("AI SDK conversion utilities", () => {
531538
expect(result).toBeUndefined()
532539
})
533540
})
541+
542+
describe("extractAiSdkErrorMessage", () => {
543+
it("should return 'Unknown error' for null/undefined", () => {
544+
expect(extractAiSdkErrorMessage(null)).toBe("Unknown error")
545+
expect(extractAiSdkErrorMessage(undefined)).toBe("Unknown error")
546+
})
547+
548+
it("should extract message from AI_RetryError", () => {
549+
const retryError = {
550+
name: "AI_RetryError",
551+
message: "Failed after 3 attempts",
552+
errors: [new Error("Error 1"), new Error("Error 2"), new Error("Too Many Requests")],
553+
lastError: { message: "Too Many Requests", status: 429 },
554+
}
555+
556+
const result = extractAiSdkErrorMessage(retryError)
557+
expect(result).toBe("Failed after 3 attempts (429): Too Many Requests")
558+
})
559+
560+
it("should handle AI_RetryError without status", () => {
561+
const retryError = {
562+
name: "AI_RetryError",
563+
message: "Failed after 2 attempts",
564+
errors: [new Error("Error 1"), new Error("Connection failed")],
565+
lastError: { message: "Connection failed" },
566+
}
567+
568+
const result = extractAiSdkErrorMessage(retryError)
569+
expect(result).toBe("Failed after 2 attempts: Connection failed")
570+
})
571+
572+
it("should extract message from AI_APICallError", () => {
573+
const apiError = {
574+
name: "AI_APICallError",
575+
message: "Rate limit exceeded",
576+
status: 429,
577+
}
578+
579+
const result = extractAiSdkErrorMessage(apiError)
580+
expect(result).toBe("API Error (429): Rate limit exceeded")
581+
})
582+
583+
it("should handle AI_APICallError without status", () => {
584+
const apiError = {
585+
name: "AI_APICallError",
586+
message: "Connection timeout",
587+
}
588+
589+
const result = extractAiSdkErrorMessage(apiError)
590+
expect(result).toBe("Connection timeout")
591+
})
592+
593+
it("should extract message from standard Error", () => {
594+
const error = new Error("Something went wrong")
595+
expect(extractAiSdkErrorMessage(error)).toBe("Something went wrong")
596+
})
597+
598+
it("should convert non-Error to string", () => {
599+
expect(extractAiSdkErrorMessage("string error")).toBe("string error")
600+
expect(extractAiSdkErrorMessage({ custom: "object" })).toBe("[object Object]")
601+
})
602+
})
603+
604+
describe("handleAiSdkError", () => {
605+
it("should wrap error with provider name", () => {
606+
const error = new Error("API Error")
607+
const result = handleAiSdkError(error, "Fireworks")
608+
609+
expect(result.message).toBe("Fireworks: API Error")
610+
})
611+
612+
it("should preserve status code from AI_RetryError", () => {
613+
const retryError = {
614+
name: "AI_RetryError",
615+
errors: [new Error("Too Many Requests")],
616+
lastError: { message: "Too Many Requests", status: 429 },
617+
}
618+
619+
const result = handleAiSdkError(retryError, "Groq")
620+
621+
expect(result.message).toContain("Groq:")
622+
expect(result.message).toContain("429")
623+
expect((result as any).status).toBe(429)
624+
})
625+
626+
it("should preserve status code from AI_APICallError", () => {
627+
const apiError = {
628+
name: "AI_APICallError",
629+
message: "Unauthorized",
630+
status: 401,
631+
}
632+
633+
const result = handleAiSdkError(apiError, "DeepSeek")
634+
635+
expect(result.message).toContain("DeepSeek:")
636+
expect(result.message).toContain("401")
637+
expect((result as any).status).toBe(401)
638+
})
639+
640+
it("should preserve original error as cause", () => {
641+
const originalError = new Error("Original error")
642+
const result = handleAiSdkError(originalError, "Cerebras")
643+
644+
expect((result as any).cause).toBe(originalError)
645+
})
646+
})
534647
})

0 commit comments

Comments
 (0)