From 3d47dcbf53af42c827afbaf0a2bc94b2ce158332 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 18:20:59 -0700 Subject: [PATCH 1/4] fix: surface actual API error messages instead of generic NoOutputGeneratedError - Add consumeAiSdkStream() shared utility that captures stream error messages and substitutes them when result.usage throws NoOutputGeneratedError - Update all 17 AI SDK providers to use the new utility - Add extractMessageFromResponseBody() for JSON error response parsing - Enhance extractAiSdkErrorMessage() with responseBody extraction - Fix duplicate error display on mid-stream failures with auto-retry - Add 18 new tests covering error extraction and stream consumption --- src/api/providers/anthropic-vertex.ts | 19 +- src/api/providers/anthropic.ts | 19 +- src/api/providers/azure.ts | 20 +- src/api/providers/baseten.ts | 17 +- src/api/providers/bedrock.ts | 20 +- src/api/providers/deepseek.ts | 21 +- src/api/providers/fireworks.ts | 21 +- src/api/providers/gemini.ts | 17 +- src/api/providers/lm-studio.ts | 17 +- src/api/providers/mistral.ts | 25 +- src/api/providers/openai-compatible.ts | 20 +- src/api/providers/openai-native.ts | 75 +++--- src/api/providers/requesty.ts | 18 +- src/api/providers/sambanova.ts | 21 +- src/api/providers/vertex.ts | 53 ++-- src/api/providers/xai.ts | 21 +- src/api/providers/zai.ts | 17 +- src/api/transform/__tests__/ai-sdk.spec.ts | 288 +++++++++++++++++++++ src/api/transform/ai-sdk.ts | 212 ++++++++++++++- src/core/task/Task.ts | 11 +- 20 files changed, 692 insertions(+), 240 deletions(-) diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 685c8628b0..30c1c602f8 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -175,6 +175,7 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple try { const result = streamText(requestOptions) + let lastStreamError: string | undefined for await (const part of result.fullStream) { // Capture thinking signature from stream events // The AI SDK's @ai-sdk/anthropic emits the signature as a reasoning-delta @@ -193,15 +194,25 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } yield chunk } } // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index f6ee47e130..e04748cbc5 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -151,6 +151,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa try { const result = streamText(requestOptions) + let lastStreamError: string | undefined for await (const part of result.fullStream) { // Capture thinking signature from stream events // The AI SDK's @ai-sdk/anthropic emits the signature as a reasoning-delta @@ -169,15 +170,25 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa } for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } yield chunk } } // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/api/providers/azure.ts b/src/api/providers/azure.ts index 5dcacb4895..559096fb55 100644 --- a/src/api/providers/azure.ts +++ b/src/api/providers/azure.ts @@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -159,19 +159,11 @@ export class AzureHandler extends BaseProvider implements SingleCompletionHandle const result = streamText(requestOptions) try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const [usage, providerMetadata] = await Promise.all([result.usage, result.providerMetadata]) + yield processUsage(usage, providerMetadata as Parameters[1]) + }) } catch (error) { // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, "Azure AI Foundry") diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index 2e63f3d52c..cb450e658c 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -118,16 +118,11 @@ export class BasetenHandler extends BaseProvider implements SingleCompletionHand const result = streamText(requestOptions) try { - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const usage = await result.usage + yield processUsage(usage) + }) } catch (error) { throw handleAiSdkError(error, "Baseten") } diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index 375dd2c042..0bb5936c2b 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -343,6 +343,8 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH try { const result = streamText(requestOptions) + let lastStreamError: string | undefined + // Process the full stream for await (const part of result.fullStream) { // Capture thinking signature from stream events. @@ -371,15 +373,25 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } yield chunk } } // Yield usage metrics at the end - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index aa1af804ea..9181ad1ce3 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -137,21 +137,12 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan const result = streamText(requestOptions) try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const [usage, providerMetadata] = await Promise.all([result.usage, result.providerMetadata]) + yield processUsage(usage, providerMetadata as Parameters[1]) + }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, "DeepSeek") } } diff --git a/src/api/providers/fireworks.ts b/src/api/providers/fireworks.ts index bc5560bfbb..453bde8ad4 100644 --- a/src/api/providers/fireworks.ts +++ b/src/api/providers/fireworks.ts @@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -137,21 +137,12 @@ export class FireworksHandler extends BaseProvider implements SingleCompletionHa const result = streamText(requestOptions) try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const [usage, providerMetadata] = await Promise.all([result.usage, result.providerMetadata]) + yield processUsage(usage, providerMetadata as Parameters[1]) + }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, "Fireworks") } } diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index f7ebfdeeb9..786222c39c 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -133,6 +133,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Track whether any text content was yielded (not just reasoning/thinking) let hasContent = false + let lastStreamError: string | undefined // Process the full stream to get all events including reasoning for await (const part of result.fullStream) { @@ -146,6 +147,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } if (chunk.type === "text" || chunk.type === "tool_call_start") { hasContent = true } @@ -163,7 +167,15 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } // Extract grounding sources from providerMetadata if available - const providerMetadata = await result.providerMetadata + let providerMetadata: Awaited + try { + providerMetadata = await result.providerMetadata + } catch (metaError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw metaError + } const groundingMetadata = providerMetadata?.google as | { groundingMetadata?: { @@ -190,6 +202,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl yield this.processUsageMetrics(usage, info, providerMetadata) } } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } if (usageError instanceof NoOutputGeneratedError) { // If we already yielded the empty-stream message, suppress this error if (hasContent) { diff --git a/src/api/providers/lm-studio.ts b/src/api/providers/lm-studio.ts index fdc95afb41..589164ff98 100644 --- a/src/api/providers/lm-studio.ts +++ b/src/api/providers/lm-studio.ts @@ -8,7 +8,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -79,16 +79,11 @@ export class LmStudioHandler extends OpenAICompatibleHandler implements SingleCo const result = streamText(requestOptions) try { - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const usage = await result.usage + yield processUsage(usage) + }) } catch (error) { throw handleAiSdkError(error, "LM Studio") } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index be6665e324..73f272a30a 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -12,12 +12,7 @@ import { import type { ApiHandlerOptions } from "../../shared/api" -import { - convertToAiSdkMessages, - convertToolsForAiSdk, - processAiSdkStreamPart, - handleAiSdkError, -} from "../transform/ai-sdk" +import { convertToAiSdkMessages, convertToolsForAiSdk, consumeAiSdkStream, handleAiSdkError } from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" @@ -170,20 +165,12 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand const result = streamText(requestOptions) try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const usage = await result.usage + yield processUsage(usage) + }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, "Mistral") } } diff --git a/src/api/providers/openai-compatible.ts b/src/api/providers/openai-compatible.ts index 8f810349ab..d92c5fbbfc 100644 --- a/src/api/providers/openai-compatible.ts +++ b/src/api/providers/openai-compatible.ts @@ -15,7 +15,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -152,19 +152,11 @@ export abstract class OpenAICompatibleHandler extends BaseProvider implements Si const result = streamText(requestOptions) try { - // Process the full stream to get all events - for await (const part of result.fullStream) { - // Use the processAiSdkStreamPart utility to convert stream parts - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const usage = await result.usage + yield processUsage(usage) + }) } catch (error) { // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, this.config.providerName) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index fb14c2bc12..d11ecdd643 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -22,7 +22,7 @@ import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -463,48 +463,55 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio const result = streamText(requestOptions) + const processUsage = this.processUsageMetrics.bind(this) + const setResponseId = (id: string) => { + this.lastResponseId = id + } + const setServiceTier = (tier: ServiceTier) => { + this.lastServiceTier = tier + } + const setEncryptedContent = (content: { encrypted_content: string; id?: string }) => { + this.lastEncryptedContent = content + } try { - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - const providerMeta = await result.providerMetadata - const openaiMeta = (providerMeta as any)?.openai + yield* consumeAiSdkStream(result, async function* () { + const providerMeta = await result.providerMetadata + const openaiMeta = providerMeta?.openai as Record | undefined - if (openaiMeta?.responseId) { - this.lastResponseId = openaiMeta.responseId - } - if (openaiMeta?.serviceTier) { - this.lastServiceTier = openaiMeta.serviceTier as ServiceTier - } + if (typeof openaiMeta?.responseId === "string") { + setResponseId(openaiMeta.responseId) + } + if (typeof openaiMeta?.serviceTier === "string") { + setServiceTier(openaiMeta.serviceTier as ServiceTier) + } - // Capture encrypted content from reasoning parts in the response - try { - const content = await (result as any).content - if (Array.isArray(content)) { - for (const part of content) { - if (part.type === "reasoning" && part.providerMetadata) { - const partMeta = (part.providerMetadata as any)?.openai - if (partMeta?.reasoningEncryptedContent) { - this.lastEncryptedContent = { - encrypted_content: partMeta.reasoningEncryptedContent, - ...(partMeta.itemId ? { id: partMeta.itemId } : {}), + // Capture encrypted content from reasoning parts in the response + try { + const content = await (result as unknown as { content?: Promise }).content + if (Array.isArray(content)) { + for (const part of content) { + const p = part as Record + if (p.type === "reasoning" && p.providerMetadata) { + const partMeta = (p.providerMetadata as Record>)?.openai + if (typeof partMeta?.reasoningEncryptedContent === "string") { + setEncryptedContent({ + encrypted_content: partMeta.reasoningEncryptedContent, + ...(typeof partMeta.itemId === "string" ? { id: partMeta.itemId } : {}), + }) + break } - break } } } + } catch { + // Content parts with encrypted reasoning may not always be available } - } catch { - // Content parts with encrypted reasoning may not always be available - } - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage, model, providerMeta as any) - } + const usage = await result.usage + if (usage) { + yield processUsage(usage, model, providerMeta as Parameters[2]) + } + }) } catch (error) { throw handleAiSdkError(error, this.providerName) } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index 630c71345e..5f8e2cbc45 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -10,7 +10,7 @@ import { calculateApiCostOpenAI } from "../../shared/cost" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -199,17 +199,11 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan const result = streamText(requestOptions) try { - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, info, providerMetadata as RequestyProviderMetadata) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const [usage, providerMetadata] = await Promise.all([result.usage, result.providerMetadata]) + yield processUsage(usage, info, providerMetadata as RequestyProviderMetadata) + }) } catch (error) { throw handleAiSdkError(error, "Requesty") } diff --git a/src/api/providers/sambanova.ts b/src/api/providers/sambanova.ts index e1fee21506..71d2b66ab5 100644 --- a/src/api/providers/sambanova.ts +++ b/src/api/providers/sambanova.ts @@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, flattenAiSdkMessagesToStringContent, @@ -142,21 +142,12 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa const result = streamText(requestOptions) try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const [usage, providerMetadata] = await Promise.all([result.usage, result.providerMetadata]) + yield processUsage(usage, providerMetadata as Parameters[1]) + }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, "SambaNova") } } diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index c772741e6a..7f1b551485 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -146,6 +146,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl const result = streamText(requestOptions) // Process the full stream to get all events including reasoning + let lastStreamError: string | undefined for await (const part of result.fullStream) { // Capture thoughtSignature from tool-call events (Gemini 3 thought signatures) // The AI SDK's tool-call event includes providerMetadata with the signature @@ -160,33 +161,43 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl } for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } yield chunk } } - // Extract grounding sources from providerMetadata if available - const providerMetadata = await result.providerMetadata - const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as - | { - groundingMetadata?: { - groundingChunks?: Array<{ - web?: { uri?: string; title?: string } - }> - } - } - | undefined - - if (groundingMetadata?.groundingMetadata) { - const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata) - if (sources.length > 0) { - yield { type: "grounding", sources } + // Extract grounding sources and usage from providerMetadata + try { + const providerMetadata = await result.providerMetadata + const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as + | { + groundingMetadata?: { + groundingChunks?: Array<{ + web?: { uri?: string; title?: string } + }> + } + } + | undefined + + if (groundingMetadata?.groundingMetadata) { + const sources = this.extractGroundingSources(groundingMetadata.groundingMetadata) + if (sources.length > 0) { + yield { type: "grounding", sources } + } } - } - // Yield usage metrics at the end - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage, info, providerMetadata) + // Yield usage metrics at the end + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage, info, providerMetadata) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/api/providers/xai.ts b/src/api/providers/xai.ts index 88a7aceb46..ace457dbfa 100644 --- a/src/api/providers/xai.ts +++ b/src/api/providers/xai.ts @@ -9,7 +9,7 @@ import type { ApiHandlerOptions } from "../../shared/api" import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -147,21 +147,12 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler const result = streamText(requestOptions) try { - // Process the full stream to get all events including reasoning - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - // Yield usage metrics at the end, including cache metrics from providerMetadata - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) - } + const processUsage = this.processUsageMetrics.bind(this) + yield* consumeAiSdkStream(result, async function* () { + const [usage, providerMetadata] = await Promise.all([result.usage, result.providerMetadata]) + yield processUsage(usage, providerMetadata as Parameters[1]) + }) } catch (error) { - // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) throw handleAiSdkError(error, "xAI") } } diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index acfdd81129..dd30ecd6d1 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -17,7 +17,7 @@ import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/a import { convertToAiSdkMessages, convertToolsForAiSdk, - processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, handleAiSdkError, } from "../transform/ai-sdk" @@ -127,20 +127,7 @@ export class ZAiHandler extends BaseProvider implements SingleCompletionHandler const result = streamText(requestOptions) try { - for await (const part of result.fullStream) { - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk - } - } - - const usage = await result.usage - if (usage) { - yield { - type: "usage" as const, - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - } - } + yield* consumeAiSdkStream(result) } catch (error) { throw handleAiSdkError(error, "Z.ai") } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index f973fc85a6..8559f01ed2 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -4,8 +4,10 @@ import { convertToAiSdkMessages, convertToolsForAiSdk, processAiSdkStreamPart, + consumeAiSdkStream, mapToolChoice, extractAiSdkErrorMessage, + extractMessageFromResponseBody, handleAiSdkError, flattenAiSdkMessagesToStringContent, } from "../ai-sdk" @@ -793,6 +795,75 @@ describe("AI SDK conversion utilities", () => { expect(extractAiSdkErrorMessage("string error")).toBe("string error") expect(extractAiSdkErrorMessage({ custom: "object" })).toBe("[object Object]") }) + + it("should extract message from AI_APICallError responseBody with JSON error", () => { + const apiError = { + name: "AI_APICallError", + message: "API call failed", + responseBody: '{"error":{"message":"Insufficient balance or no resource package.","code":"1113"}}', + statusCode: 402, + } + + const result = extractAiSdkErrorMessage(apiError) + expect(result).toContain("Insufficient balance") + expect(result).not.toBe("API call failed") + }) + + it("should fall back to message when AI_APICallError responseBody is non-JSON", () => { + const apiError = { + name: "AI_APICallError", + message: "Server error", + responseBody: "Internal Server Error", + statusCode: 500, + } + + const result = extractAiSdkErrorMessage(apiError) + expect(result).toContain("Server error") + }) + + it("should extract message from AI_RetryError lastError responseBody", () => { + const retryError = { + name: "AI_RetryError", + message: "Failed after retries", + lastError: { + name: "AI_APICallError", + message: "API call failed", + responseBody: '{"error":{"message":"Rate limit exceeded"}}', + statusCode: 429, + }, + errors: [{}], + } + + const result = extractAiSdkErrorMessage(retryError) + expect(result).toContain("Rate limit exceeded") + }) + + it("should extract message from NoOutputGeneratedError with APICallError cause", () => { + const error = { + name: "AI_NoOutputGeneratedError", + message: "No output generated", + cause: { + name: "AI_APICallError", + message: "Forbidden", + responseBody: '{"error":{"message":"Insufficient balance"}}', + statusCode: 403, + }, + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toContain("Insufficient balance") + expect(result).not.toBe("No output generated") + }) + + it("should return own message from NoOutputGeneratedError without useful cause", () => { + const error = { + name: "AI_NoOutputGeneratedError", + message: "No output generated", + } + + const result = extractAiSdkErrorMessage(error) + expect(result).toBe("No output generated") + }) }) describe("handleAiSdkError", () => { @@ -839,6 +910,41 @@ describe("AI SDK conversion utilities", () => { }) }) + describe("extractMessageFromResponseBody", () => { + it("should extract message with code from error object", () => { + const body = '{"error": {"message": "Insufficient balance", "code": "1113"}}' + expect(extractMessageFromResponseBody(body)).toBe("[1113] Insufficient balance") + }) + + it("should extract message from error object without code", () => { + const body = '{"error": {"message": "Rate limit exceeded"}}' + expect(extractMessageFromResponseBody(body)).toBe("Rate limit exceeded") + }) + + it("should extract message from error string field", () => { + const body = '{"error": "Something went wrong"}' + expect(extractMessageFromResponseBody(body)).toBe("Something went wrong") + }) + + it("should extract message from top-level message field", () => { + const body = '{"message": "Bad request"}' + expect(extractMessageFromResponseBody(body)).toBe("Bad request") + }) + + it("should return undefined for non-JSON string", () => { + expect(extractMessageFromResponseBody("Not Found")).toBeUndefined() + }) + + it("should return undefined for empty string", () => { + expect(extractMessageFromResponseBody("")).toBeUndefined() + }) + + it("should return undefined for JSON without error fields", () => { + const body = '{"status": "ok"}' + expect(extractMessageFromResponseBody(body)).toBeUndefined() + }) + }) + describe("flattenAiSdkMessagesToStringContent", () => { it("should return messages unchanged if content is already a string", () => { const messages = [ @@ -1061,3 +1167,185 @@ describe("AI SDK conversion utilities", () => { }) }) }) + +describe("consumeAiSdkStream", () => { + /** + * Helper to create an AsyncIterable from an array of stream parts. + */ + async function* createAsyncIterable(items: T[]): AsyncGenerator { + for (const item of items) { + yield item + } + } + + /** + * Helper to collect all chunks from an async generator. + * Returns { chunks, error } to support both success and error paths. + */ + async function collectStream(stream: AsyncGenerator): Promise<{ chunks: unknown[]; error: Error | null }> { + const chunks: unknown[] = [] + let error: Error | null = null + try { + for await (const chunk of stream) { + chunks.push(chunk) + } + } catch (e) { + error = e instanceof Error ? e : new Error(String(e)) + } + return { chunks, error } + } + + it("yields stream chunks from fullStream", async () => { + const result = { + fullStream: createAsyncIterable([ + { type: "text-delta" as const, id: "1", text: "hello" }, + { type: "text" as const, text: " world" }, + ]), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + } + + const { chunks, error } = await collectStream(consumeAiSdkStream(result as any)) + + expect(error).toBeNull() + // Two text chunks + one usage chunk + expect(chunks).toHaveLength(3) + expect(chunks[0]).toEqual({ type: "text", text: "hello" }) + expect(chunks[1]).toEqual({ type: "text", text: " world" }) + }) + + it("yields default usage chunk when no usageHandler provided", async () => { + const result = { + fullStream: createAsyncIterable([{ type: "text-delta" as const, id: "1", text: "hi" }]), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + } + + const { chunks, error } = await collectStream(consumeAiSdkStream(result as any)) + + expect(error).toBeNull() + const usageChunk = chunks.find((c: any) => c.type === "usage") + expect(usageChunk).toEqual({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + }) + }) + + it("uses usageHandler when provided", async () => { + const result = { + fullStream: createAsyncIterable([{ type: "text-delta" as const, id: "1", text: "hi" }]), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + } + + async function* customUsageHandler() { + yield { + type: "usage" as const, + inputTokens: 42, + outputTokens: 84, + cacheWriteTokens: 5, + cacheReadTokens: 3, + } + } + + const { chunks, error } = await collectStream(consumeAiSdkStream(result as any, customUsageHandler)) + + expect(error).toBeNull() + const usageChunk = chunks.find((c: any) => c.type === "usage") + expect(usageChunk).toEqual({ + type: "usage", + inputTokens: 42, + outputTokens: 84, + cacheWriteTokens: 5, + cacheReadTokens: 3, + }) + }) + + /** + * THE KEY TEST: Verifies that when the stream contains an error chunk (e.g. "Insufficient balance") + * and result.usage rejects with a generic error (AI SDK's NoOutputGeneratedError), the thrown + * error preserves the specific stream error message rather than the generic one. + */ + it("captures stream error and throws it when usage fails", async () => { + const usageRejection = Promise.reject(new Error("No output generated. Check the stream for errors.")) + // Prevent unhandled rejection warning — the rejection is intentionally caught inside consumeAiSdkStream + usageRejection.catch(() => {}) + + const result = { + fullStream: createAsyncIterable([ + { type: "text-delta" as const, id: "1", text: "partial" }, + { + type: "error" as const, + error: new Error("Insufficient balance to complete this request"), + }, + ]), + usage: usageRejection, + } + + const { chunks, error } = await collectStream(consumeAiSdkStream(result as any)) + + // The error chunk IS still yielded during stream iteration + const errorChunk = chunks.find((c: any) => c.type === "error") + expect(errorChunk).toEqual({ + type: "error", + error: "StreamError", + message: "Insufficient balance to complete this request", + }) + + // The thrown error uses the captured stream error, NOT the generic usage error + expect(error).not.toBeNull() + expect(error!.message).toBe("Insufficient balance to complete this request") + expect(error!.message).not.toContain("No output generated") + }) + + it("re-throws usage error when no stream error captured", async () => { + const usageRejection = Promise.reject(new Error("Rate limit exceeded")) + usageRejection.catch(() => {}) + + const result = { + fullStream: createAsyncIterable([{ type: "text-delta" as const, id: "1", text: "hello" }]), + usage: usageRejection, + } + + const { chunks, error } = await collectStream(consumeAiSdkStream(result as any)) + + // Text chunk should still be yielded + expect(chunks).toHaveLength(1) + expect(chunks[0]).toEqual({ type: "text", text: "hello" }) + + // The original usage error is re-thrown since no stream error was captured + expect(error).not.toBeNull() + expect(error!.message).toBe("Rate limit exceeded") + }) + + it("captures stream error and throws it when usageHandler fails", async () => { + const result = { + fullStream: createAsyncIterable([ + { type: "text-delta" as const, id: "1", text: "partial" }, + { + type: "error" as const, + error: new Error("Insufficient balance to complete this request"), + }, + ]), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + } + + // eslint-disable-next-line require-yield + async function* failingUsageHandler(): AsyncGenerator { + throw new Error("No output generated. Check the stream for errors.") + } + + const { chunks, error } = await collectStream(consumeAiSdkStream(result as any, failingUsageHandler)) + + // Error chunk was yielded during streaming + const errorChunk = chunks.find((c: any) => c.type === "error") + expect(errorChunk).toEqual({ + type: "error", + error: "StreamError", + message: "Insufficient balance to complete this request", + }) + + // The thrown error uses the captured stream error, not the usageHandler error + expect(error).not.toBeNull() + expect(error!.message).toBe("Insufficient balance to complete this request") + expect(error!.message).not.toContain("No output generated") + }) +}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index c673fad3d2..14b3ad6dbc 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -6,7 +6,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai" -import type { ApiStreamChunk } from "./stream" +import type { ApiStreamChunk, ApiStream } from "./stream" /** * Options for converting Anthropic messages to AI SDK format. @@ -460,6 +460,59 @@ export function* processAiSdkStreamPart(part: ExtendedStreamPart): Generator + usage: PromiseLike<{ inputTokens?: number; outputTokens?: number }> + }, + usageHandler?: () => AsyncGenerator, +): ApiStream { + 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 + } + } + + try { + if (usageHandler) { + yield* usageHandler() + } else { + const usage = await result.usage + if (usage) { + yield { + type: "usage" as const, + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + } + } + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError + } +} + /** * Type for AI SDK tool choice format. */ @@ -501,6 +554,58 @@ export function mapToolChoice(toolChoice: any): AiSdkToolChoice { return undefined } +/** + * Extract a human-readable error message from an API response body string. + * Handles common JSON error formats returned by AI providers. + * + * @param responseBody - The raw HTTP response body string + * @returns The extracted error message, or undefined if none found + */ +export function extractMessageFromResponseBody(responseBody: string): string | undefined { + if (!responseBody || typeof responseBody !== "string") { + return undefined + } + + try { + const parsed: unknown = JSON.parse(responseBody) + + if (typeof parsed !== "object" || parsed === null) { + return undefined + } + + const obj = parsed as Record + + // Format: {"error": {"message": "...", "code": "..."}} or {"error": {"message": "..."}} + if (typeof obj.error === "object" && obj.error !== null) { + const errorObj = obj.error as Record + if (typeof errorObj.message === "string" && errorObj.message) { + if (typeof errorObj.code === "string" && errorObj.code) { + return `[${errorObj.code}] ${errorObj.message}` + } + if (typeof errorObj.code === "number") { + return `[${errorObj.code}] ${errorObj.message}` + } + return errorObj.message + } + } + + // Format: {"error": "string message"} + if (typeof obj.error === "string" && obj.error) { + return obj.error + } + + // Format: {"message": "..."} + if (typeof obj.message === "string" && obj.message) { + return obj.message + } + + return undefined + } catch { + // JSON parse failed — responseBody is not valid JSON + 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 @@ -514,18 +619,41 @@ export function extractAiSdkErrorMessage(error: unknown): string { return "Unknown error" } - // Cast to access AI SDK error properties - const anyError = error as any + if (typeof error !== "object") { + return String(error) + } + + const errorObj = error as Record // AI_RetryError has a lastError property with the actual error - if (anyError.name === "AI_RetryError") { - const retryCount = anyError.errors?.length || 0 - const lastError = anyError.lastError - const lastErrorMessage = lastError?.message || lastError?.toString() || "Unknown error" + if (errorObj.name === "AI_RetryError") { + const retryCount = Array.isArray(errorObj.errors) ? errorObj.errors.length : 0 + const lastError = errorObj.lastError + + // Try to extract message from lastError's responseBody first + let lastErrorMessage: string | undefined + if ( + typeof lastError === "object" && + lastError !== null && + "responseBody" in lastError && + typeof (lastError as Record).responseBody === "string" + ) { + lastErrorMessage = extractMessageFromResponseBody( + (lastError as Record).responseBody as string, + ) + } + + if (!lastErrorMessage) { + lastErrorMessage = + typeof lastError === "object" && lastError !== null && "message" in lastError + ? String((lastError as Record).message) + : lastError + ? String(lastError) + : "Unknown error" + } // Extract status code if available - const statusCode = - lastError?.status || lastError?.statusCode || anyError.status || anyError.statusCode || undefined + const statusCode = getStatusCode(lastError) ?? getStatusCode(error) if (statusCode) { return `Failed after ${retryCount} attempts (${statusCode}): ${lastErrorMessage}` @@ -533,13 +661,52 @@ export function extractAiSdkErrorMessage(error: unknown): string { return `Failed after ${retryCount} attempts: ${lastErrorMessage}` } - // AI_APICallError has message and optional status - if (anyError.name === "AI_APICallError") { - const statusCode = anyError.status || anyError.statusCode + // AI_APICallError has message, optional status, and responseBody + if (errorObj.name === "AI_APICallError") { + const statusCode = getStatusCode(error) + + // Try to extract a richer message from responseBody + let message: string | undefined + if ("responseBody" in errorObj && typeof errorObj.responseBody === "string") { + message = extractMessageFromResponseBody(errorObj.responseBody) + } + + if (!message) { + message = typeof errorObj.message === "string" ? errorObj.message : "API call failed" + } + if (statusCode) { - return `API Error (${statusCode}): ${anyError.message}` + return `API Error (${statusCode}): ${message}` + } + return message + } + + // AI_NoOutputGeneratedError wraps a cause that may be an APICallError + if (errorObj.name === "AI_NoOutputGeneratedError" || errorObj.name === "NoOutputGeneratedError") { + const cause = errorObj.cause + if (typeof cause === "object" && cause !== null) { + const causeObj = cause as Record + // If cause is an AI_APICallError, recursively extract its message + if (causeObj.name === "AI_APICallError") { + return extractAiSdkErrorMessage(cause) + } + // Try responseBody on the cause directly + if ("responseBody" in causeObj && typeof causeObj.responseBody === "string") { + const bodyMessage = extractMessageFromResponseBody(causeObj.responseBody) + if (bodyMessage) { + return bodyMessage + } + } + // Fall through to cause's message + if ("message" in causeObj && typeof causeObj.message === "string") { + return causeObj.message + } } - return anyError.message || "API call failed" + // Fall back to the error's own message + if (typeof errorObj.message === "string" && errorObj.message) { + return errorObj.message + } + return "No output generated" } // Standard Error @@ -551,6 +718,23 @@ export function extractAiSdkErrorMessage(error: unknown): string { return String(error) } +/** + * Extract a numeric status code from an error-like object. + */ +function getStatusCode(obj: unknown): number | undefined { + if (typeof obj !== "object" || obj === null) { + return undefined + } + const record = obj as Record + if (typeof record.status === "number") { + return record.status + } + if (typeof record.statusCode === "number") { + return record.statusCode + } + return undefined +} + /** * Handle AI SDK errors by extracting the message and preserving status codes. * Returns an Error object with proper status preserved for retry logic. diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index caea2e9e09..87ef569f82 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3340,9 +3340,17 @@ export class Task extends EventEmitter implements TaskLike { const cancelReason: ClineApiReqCancelReason = this.abort ? "user_cancelled" : "streaming_failed" const rawErrorMessage = error.message ?? JSON.stringify(serializeError(error), null, 2) + + // 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. + const stateForBackoff = await this.providerRef.deref()?.getState() + const willAutoRetry = !this.abort && stateForBackoff?.autoApprovalEnabled + const streamingFailedMessage = this.abort ? undefined - : `${t("common:interruption.streamTerminatedByProvider")}: ${rawErrorMessage}` + : willAutoRetry + ? undefined // backoffAndAnnounce will display the error with retry countdown + : `${t("common:interruption.streamTerminatedByProvider")}: ${rawErrorMessage}` // Clean up partial state await abortStream(cancelReason, streamingFailedMessage) @@ -3359,7 +3367,6 @@ export class Task extends EventEmitter implements TaskLike { ) // Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled - const stateForBackoff = await this.providerRef.deref()?.getState() if (stateForBackoff?.autoApprovalEnabled) { await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error) From 739f64a0f185e30018f0c575fe78decad8699d3f Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 18:39:18 -0700 Subject: [PATCH 2/4] fix: use rawErrorMessage in debug log when streamingFailedMessage is undefined --- src/core/task/Task.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 87ef569f82..7debd24092 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -3363,7 +3363,7 @@ export class Task extends EventEmitter implements TaskLike { // Stream failed - log the error and retry with the same content // The existing rate limiting will prevent rapid retries console.error( - `[Task#${this.taskId}.${this.instanceId}] Stream failed, will retry: ${streamingFailedMessage}`, + `[Task#${this.taskId}.${this.instanceId}] Stream failed, will retry: ${rawErrorMessage}`, ) // Apply exponential backoff similar to first-chunk errors when auto-resubmit is enabled From 01fbb30038980ee58b05abad2e6ffabac8602882 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 19:00:39 -0700 Subject: [PATCH 3/4] fix: add stream error capture to remaining 4 AI SDK providers --- src/api/providers/minimax.ts | 70 ++++++++++-------- src/api/providers/openai-codex.ts | 99 +++++++++++++++----------- src/api/providers/openai.ts | 20 ++++-- src/api/providers/vercel-ai-gateway.ts | 20 ++++-- 4 files changed, 129 insertions(+), 80 deletions(-) diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 07a4978af9..805a512284 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -128,40 +128,52 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand try { const result = streamText(requestOptions as Parameters[0]) - - for await (const part of result.fullStream) { - const anthropicMetadata = ( - part as { - providerMetadata?: { - anthropic?: { - signature?: string - redactedData?: string + + let lastStreamError: string | undefined + + for await (const part of result.fullStream) { + const anthropicMetadata = ( + part as { + providerMetadata?: { + anthropic?: { + signature?: string + redactedData?: string + } } } + ).providerMetadata?.anthropic + + if (anthropicMetadata?.signature) { + this.lastThoughtSignature = anthropicMetadata.signature + } + + if (anthropicMetadata?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: anthropicMetadata.redactedData, + }) + } + + for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message + } + yield chunk } - ).providerMetadata?.anthropic - - if (anthropicMetadata?.signature) { - this.lastThoughtSignature = anthropicMetadata.signature - } - - if (anthropicMetadata?.redactedData) { - this.lastRedactedThinkingBlocks.push({ - type: "redacted_thinking", - data: anthropicMetadata.redactedData, - }) } - - for (const chunk of processAiSdkStreamPart(part)) { - yield chunk + + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } - } - - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) - } } catch (error) { throw handleAiSdkError(error, this.providerName) } diff --git a/src/api/providers/openai-codex.ts b/src/api/providers/openai-codex.ts index 0263cae43d..410d9fbdac 100644 --- a/src/api/providers/openai-codex.ts +++ b/src/api/providers/openai-codex.ts @@ -203,64 +203,77 @@ export class OpenAiCodexHandler extends BaseProvider implements SingleCompletion }) // Stream parts + 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 } } - // Extract metadata from completed response - const providerMeta = await result.providerMetadata - const openaiMeta = (providerMeta as any)?.openai + // Extract metadata and usage — wrap in try/catch for stream error fallback + try { + // Extract metadata from completed response + const providerMeta = await result.providerMetadata + const openaiMeta = (providerMeta as any)?.openai - if (openaiMeta?.responseId) { - this.lastResponseId = openaiMeta.responseId - } + if (openaiMeta?.responseId) { + this.lastResponseId = openaiMeta.responseId + } - // Capture encrypted content from reasoning parts in the response - try { - const content = await (result as any).content - if (Array.isArray(content)) { - for (const part of content) { - if (part.type === "reasoning" && part.providerMetadata) { - const partMeta = (part.providerMetadata as any)?.openai - if (partMeta?.reasoningEncryptedContent) { - this.lastEncryptedContent = { - encrypted_content: partMeta.reasoningEncryptedContent, - ...(partMeta.itemId ? { id: partMeta.itemId } : {}), + // Capture encrypted content from reasoning parts in the response + try { + const content = await (result as any).content + if (Array.isArray(content)) { + for (const part of content) { + if (part.type === "reasoning" && part.providerMetadata) { + const partMeta = (part.providerMetadata as any)?.openai + if (partMeta?.reasoningEncryptedContent) { + this.lastEncryptedContent = { + encrypted_content: partMeta.reasoningEncryptedContent, + ...(partMeta.itemId ? { id: partMeta.itemId } : {}), + } + break } - break } } } + } catch { + // Content parts with encrypted reasoning may not always be available } - } catch { - // Content parts with encrypted reasoning may not always be available - } - // Yield usage — subscription pricing means totalCost is always 0 - const usage = await result.usage - if (usage) { - const inputTokens = usage.inputTokens || 0 - const outputTokens = usage.outputTokens || 0 - const details = (usage as any).details as - | { cachedInputTokens?: number; reasoningTokens?: number } - | undefined - const cacheReadTokens = details?.cachedInputTokens ?? 0 - // The OpenAI Responses API does not report cache write tokens separately; - // only cached (read) tokens are available via usage.details.cachedInputTokens. - const cacheWriteTokens = 0 - const reasoningTokens = details?.reasoningTokens - - yield { - type: "usage", - inputTokens, - outputTokens, - cacheWriteTokens: cacheWriteTokens || undefined, - cacheReadTokens: cacheReadTokens || undefined, - ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), - totalCost: 0, // Subscription-based pricing + // Yield usage — subscription pricing means totalCost is always 0 + const usage = await result.usage + if (usage) { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + const details = (usage as any).details as + | { cachedInputTokens?: number; reasoningTokens?: number } + | undefined + const cacheReadTokens = details?.cachedInputTokens ?? 0 + // The OpenAI Responses API does not report cache write tokens separately; + // only cached (read) tokens are available via usage.details.cachedInputTokens. + const cacheWriteTokens = 0 + const reasoningTokens = details?.reasoningTokens + + yield { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), + totalCost: 0, // Subscription-based pricing + } + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) } + throw usageError } // Success — exit the retry loop diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index f4b44c519c..29ae5f0b32 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -198,8 +198,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ) 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 + } if (chunk.type === "text") { for (const matchedChunk of matcher.update(chunk.text)) { yield matchedChunk @@ -214,10 +219,17 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl yield chunk } - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any) + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } } catch (error) { throw handleAiSdkError(error, this.providerName) diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts index a959f90a21..7184468343 100644 --- a/src/api/providers/vercel-ai-gateway.ts +++ b/src/api/providers/vercel-ai-gateway.ts @@ -134,16 +134,28 @@ export class VercelAiGatewayHandler extends BaseProvider implements SingleComple }) 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 - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, providerMetadata as any) + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, providerMetadata as any) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError } } catch (error) { throw handleAiSdkError(error, "Vercel AI Gateway") From ecd3c0766b341da0585661dd90eac472df86adfc Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Mon, 9 Feb 2026 19:19:12 -0700 Subject: [PATCH 4/4] style: fix indentation in minimax.ts stream error capture --- src/api/providers/minimax.ts | 78 ++++++++++++++++++------------------ 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/src/api/providers/minimax.ts b/src/api/providers/minimax.ts index 805a512284..d1fcc1c364 100644 --- a/src/api/providers/minimax.ts +++ b/src/api/providers/minimax.ts @@ -129,51 +129,51 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand try { const result = streamText(requestOptions as Parameters[0]) - let lastStreamError: string | undefined - - for await (const part of result.fullStream) { - const anthropicMetadata = ( - part as { - providerMetadata?: { - anthropic?: { - signature?: string - redactedData?: string - } + let lastStreamError: string | undefined + + for await (const part of result.fullStream) { + const anthropicMetadata = ( + part as { + providerMetadata?: { + anthropic?: { + signature?: string + redactedData?: string } } - ).providerMetadata?.anthropic - - if (anthropicMetadata?.signature) { - this.lastThoughtSignature = anthropicMetadata.signature - } - - if (anthropicMetadata?.redactedData) { - this.lastRedactedThinkingBlocks.push({ - type: "redacted_thinking", - data: anthropicMetadata.redactedData, - }) - } - - for (const chunk of processAiSdkStreamPart(part)) { - if (chunk.type === "error") { - lastStreamError = chunk.message - } - yield chunk } + ).providerMetadata?.anthropic + + if (anthropicMetadata?.signature) { + this.lastThoughtSignature = anthropicMetadata.signature } - - try { - const usage = await result.usage - const providerMetadata = await result.providerMetadata - if (usage) { - yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) - } - } catch (usageError) { - if (lastStreamError) { - throw new Error(lastStreamError) + + if (anthropicMetadata?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: anthropicMetadata.redactedData, + }) + } + + for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "error") { + lastStreamError = chunk.message } - throw usageError + yield chunk } + } + + try { + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (usageError) { + if (lastStreamError) { + throw new Error(lastStreamError) + } + throw usageError + } } catch (error) { throw handleAiSdkError(error, this.providerName) }