mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
fix: surface actual API error messages instead of generic NoOutputGeneratedError (#11359)
This commit is contained in:
parent
938e6994a2
commit
2f9849071d
24 changed files with 797 additions and 296 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<typeof processUsage>[1])
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "Azure AI Foundry")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<typeof processUsage>[1])
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "DeepSeek")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof processUsage>[1])
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "Fireworks")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof result.providerMetadata>
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
|
|||
|
||||
try {
|
||||
const result = streamText(requestOptions as Parameters<typeof streamText>[0])
|
||||
|
||||
let lastStreamError: string | undefined
|
||||
|
||||
for await (const part of result.fullStream) {
|
||||
const anthropicMetadata = (
|
||||
|
|
@ -153,14 +155,24 @@ export class MiniMaxHandler extends BaseProvider implements SingleCompletionHand
|
|||
}
|
||||
|
||||
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, 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) {
|
||||
throw handleAiSdkError(error, this.providerName)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
if (openaiMeta?.responseId) {
|
||||
this.lastResponseId = openaiMeta.responseId
|
||||
}
|
||||
|
||||
// Capture encrypted content from reasoning parts in the response
|
||||
// Extract metadata and usage — wrap in try/catch for stream error fallback
|
||||
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 } : {}),
|
||||
// Extract metadata from completed response
|
||||
const providerMeta = await result.providerMetadata
|
||||
const openaiMeta = (providerMeta as any)?.openai
|
||||
|
||||
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 } : {}),
|
||||
}
|
||||
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 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 {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
yield* consumeAiSdkStream(result, async function* () {
|
||||
const providerMeta = await result.providerMetadata
|
||||
const openaiMeta = providerMeta?.openai as Record<string, unknown> | undefined
|
||||
|
||||
if (typeof openaiMeta?.responseId === "string") {
|
||||
setResponseId(openaiMeta.responseId)
|
||||
}
|
||||
if (typeof openaiMeta?.serviceTier === "string") {
|
||||
setServiceTier(openaiMeta.serviceTier as ServiceTier)
|
||||
}
|
||||
}
|
||||
|
||||
const providerMeta = await result.providerMetadata
|
||||
const openaiMeta = (providerMeta as any)?.openai
|
||||
|
||||
if (openaiMeta?.responseId) {
|
||||
this.lastResponseId = openaiMeta.responseId
|
||||
}
|
||||
if (openaiMeta?.serviceTier) {
|
||||
this.lastServiceTier = 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<unknown[]> }).content
|
||||
if (Array.isArray(content)) {
|
||||
for (const part of content) {
|
||||
const p = part as Record<string, unknown>
|
||||
if (p.type === "reasoning" && p.providerMetadata) {
|
||||
const partMeta = (p.providerMetadata as Record<string, Record<string, unknown>>)?.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<typeof processUsage>[2])
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
throw handleAiSdkError(error, this.providerName)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof processUsage>[1])
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "SambaNova")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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 }
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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<typeof processUsage>[1])
|
||||
})
|
||||
} catch (error) {
|
||||
// Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.)
|
||||
throw handleAiSdkError(error, "xAI")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T>(items: T[]): AsyncGenerator<T> {
|
||||
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<unknown>): 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<never> {
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<Api
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume an AI SDK stream result, processing stream parts and handling usage.
|
||||
* Centralizes the common stream consumption pattern shared across all AI SDK
|
||||
* providers, with built-in error recovery that preserves stream error messages
|
||||
* when usage resolution throws (e.g. AI SDK's NoOutputGeneratedError).
|
||||
*
|
||||
* @param result - The stream result object from AI SDK's `streamText()`.
|
||||
* Must have `fullStream` and `usage` properties.
|
||||
* @param usageHandler - Optional async generator that handles usage processing.
|
||||
* When provided, the handler is responsible for awaiting usage/providerMetadata
|
||||
* and yielding usage chunks. When omitted, a default handler awaits
|
||||
* `result.usage` and yields a basic usage chunk with inputTokens/outputTokens.
|
||||
* @yields ApiStreamChunk objects from the stream and usage processing
|
||||
*/
|
||||
export async function* consumeAiSdkStream(
|
||||
result: {
|
||||
fullStream: AsyncIterable<ExtendedStreamPart>
|
||||
usage: PromiseLike<{ inputTokens?: number; outputTokens?: number }>
|
||||
},
|
||||
usageHandler?: () => AsyncGenerator<ApiStreamChunk>,
|
||||
): 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<string, unknown>
|
||||
|
||||
// Format: {"error": {"message": "...", "code": "..."}} or {"error": {"message": "..."}}
|
||||
if (typeof obj.error === "object" && obj.error !== null) {
|
||||
const errorObj = obj.error as Record<string, unknown>
|
||||
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<string, unknown>
|
||||
|
||||
// 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<string, unknown>).responseBody === "string"
|
||||
) {
|
||||
lastErrorMessage = extractMessageFromResponseBody(
|
||||
(lastError as Record<string, unknown>).responseBody as string,
|
||||
)
|
||||
}
|
||||
|
||||
if (!lastErrorMessage) {
|
||||
lastErrorMessage =
|
||||
typeof lastError === "object" && lastError !== null && "message" in lastError
|
||||
? String((lastError as Record<string, unknown>).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
|
||||
if (statusCode) {
|
||||
return `API Error (${statusCode}): ${anyError.message}`
|
||||
// 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)
|
||||
}
|
||||
return anyError.message || "API call failed"
|
||||
|
||||
if (!message) {
|
||||
message = typeof errorObj.message === "string" ? errorObj.message : "API call failed"
|
||||
}
|
||||
|
||||
if (statusCode) {
|
||||
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<string, unknown>
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// 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<string, unknown>
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -3340,9 +3340,17 @@ export class Task extends EventEmitter<TaskEvents> 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)
|
||||
|
|
@ -3355,11 +3363,10 @@ export class Task extends EventEmitter<TaskEvents> 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
|
||||
const stateForBackoff = await this.providerRef.deref()?.getState()
|
||||
if (stateForBackoff?.autoApprovalEnabled) {
|
||||
await this.backoffAndAnnounce(currentItem.retryAttempt ?? 0, error)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue