Skip to content

Commit cce4800

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@ae3b428c053013f6c40d893c785f6f383a1eefb7
1 parent 3701627 commit cce4800

4 files changed

Lines changed: 155 additions & 7 deletions

File tree

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,57 @@ describe('OpenAICompatibleChatLanguageModel doStream', () => {
159159
expect(finishPartOf(parts).finishReason).toBe('error')
160160
})
161161

162+
it('carries provider detail and status out of a mid-stream error chunk', async () => {
163+
// OpenRouter's mid-stream provider refusals say only "Provider returned
164+
// error" in `message`; the upstream's actual words live in `metadata.raw`.
165+
// Surfacing just the message left users (most visibly on Freebuff Web)
166+
// with "Agent run error: Provider returned error" and nothing else, while
167+
// the same failure on the non-stream path shows the full
168+
// `message [provider: raw]` form built by the server. The stream path
169+
// must produce the same shape: an APICallError with the enhanced message,
170+
// the numeric code as statusCode, and a responseBody that
171+
// extractApiErrorDetails can parse like any failed HTTP response.
172+
const parts = await streamParts(
173+
sseResponse([
174+
JSON.stringify({
175+
id: 'gen-2',
176+
object: 'chat.completion.chunk',
177+
created: 1,
178+
model: 'deepseek/deepseek-v4-flash',
179+
provider: 'DeepSeek',
180+
choices: [],
181+
error: {
182+
code: 429,
183+
message: 'Provider returned error',
184+
metadata: {
185+
raw: 'deepseek-v4-flash is temporarily rate-limited upstream.',
186+
provider_name: 'DeepSeek',
187+
},
188+
},
189+
}),
190+
]),
191+
)
192+
193+
const errorPart = parts.find((part) => part.type === 'error')
194+
if (!errorPart || errorPart.type !== 'error') {
195+
throw new Error('stream swallowed the provider error')
196+
}
197+
const apiError = errorPart.error as {
198+
message: string
199+
statusCode?: number
200+
responseBody?: string
201+
}
202+
expect(apiError.message).toBe(
203+
'Provider returned error [DeepSeek: deepseek-v4-flash is temporarily rate-limited upstream.]',
204+
)
205+
expect(apiError.statusCode).toBe(429)
206+
const parsedBody = JSON.parse(apiError.responseBody ?? '{}')
207+
expect(parsedBody.error.message).toBe(apiError.message)
208+
expect(parsedBody.error.code).toBe(429)
209+
210+
expect(finishPartOf(parts).finishReason).toBe('error')
211+
})
212+
162213
it('assembles streamed reasoning_details onto the reasoning-end part', async () => {
163214
const parts = await streamParts(
164215
sseResponse([

packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { convertToOpenAICompatibleChatMessages } from './convert-to-openai-compa
1515
import { getResponseMetadata } from './get-response-metadata'
1616
import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason'
1717
import { openaiCompatibleProviderOptions } from './openai-compatible-chat-options'
18-
import { defaultOpenAICompatibleErrorStructure } from '../openai-compatible-error'
18+
import {
19+
defaultOpenAICompatibleErrorStructure,
20+
streamErrorChunkToApiCallError,
21+
} from '../openai-compatible-error'
1922
import { prepareTools } from './openai-compatible-prepare-tools'
2023

2124
import type { OpenAICompatibleChatModelId } from './openai-compatible-chat-options'
@@ -344,11 +347,12 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
344347
const metadataExtractor =
345348
this.config.metadataExtractor?.createStreamExtractor()
346349

350+
const url = this.config.url({
351+
path: '/chat/completions',
352+
modelId: this.modelId,
353+
})
347354
const { responseHeaders, value: response } = await postJsonToApi({
348-
url: this.config.url({
349-
path: '/chat/completions',
350-
modelId: this.modelId,
351-
}),
355+
url,
352356
headers: combineHeaders(this.config.headers(), options.headers),
353357
body,
354358
failedResponseHandler: this.failedResponseHandler,
@@ -463,7 +467,14 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
463467
// handle error chunks:
464468
if ('error' in value) {
465469
finishReason = 'error'
466-
controller.enqueue({ type: 'error', error: value.error.message })
470+
controller.enqueue({
471+
type: 'error',
472+
error: streamErrorChunkToApiCallError({
473+
errorValue: value.error,
474+
url,
475+
requestBodyValues: body,
476+
}),
477+
})
467478
return
468479
}
469480

packages/llm-providers/src/openai-compatible/openai-compatible-error.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { APICallError } from '@ai-sdk/provider'
12
import { z } from 'zod/v4'
23

34
import type { ZodType } from 'zod/v4'
@@ -12,6 +13,17 @@ export const openaiCompatibleErrorDataSchema = z.object({
1213
type: z.string().nullish(),
1314
param: z.any().nullish(),
1415
code: z.union([z.string(), z.number()]).nullish(),
16+
17+
// OpenRouter provider failures put the upstream's own words here — for a
18+
// mid-stream refusal, `message` is often just "Provider returned error"
19+
// and `metadata.raw` is the only place the actual reason exists. Loose so
20+
// provider-specific extras (e.g. `error_type`) survive into responseBody.
21+
metadata: z
22+
.looseObject({
23+
raw: z.string().nullish(),
24+
provider_name: z.string().nullish(),
25+
})
26+
.nullish(),
1527
}),
1628
})
1729

@@ -30,3 +42,77 @@ export const defaultOpenAICompatibleErrorStructure: ProviderErrorStructure<OpenA
3042
errorSchema: openaiCompatibleErrorDataSchema,
3143
errorToMessage: (data) => data.error.message,
3244
}
45+
46+
// Matches buildEnhancedErrorMessage on the server (web/src/llm-api/
47+
// openrouter.ts), which applies the same bound to upstream prose.
48+
const MAX_PROVIDER_DETAIL_LENGTH = 1000
49+
50+
/**
51+
* Convert a mid-stream error chunk into an APICallError that carries
52+
* everything the failed-response path already gives clients.
53+
*
54+
* Error chunks arrive inside an HTTP 200 stream, so they bypass the JSON
55+
* error response handler entirely. Surfacing only `error.message` here left
56+
* the user-facing failure as the provider's generic wording ("Provider
57+
* returned error") with the actual reason discarded in `metadata.raw`, and —
58+
* with no statusCode or responseBody for extractApiErrorDetails to find —
59+
* every client rendered it behind an "Agent run error:" prefix with no
60+
* detail. This builds the same `message [provider: raw]` form the server's
61+
* parseOpenRouterError produces for non-stream failures, and encodes the
62+
* result as a responseBody so downstream error extraction treats both
63+
* failure shapes identically.
64+
*/
65+
export function streamErrorChunkToApiCallError(params: {
66+
errorValue: unknown
67+
url: string
68+
requestBodyValues: unknown
69+
}): APICallError {
70+
const { errorValue, url, requestBodyValues } = params
71+
const error =
72+
errorValue && typeof errorValue === 'object'
73+
? (errorValue as {
74+
message?: unknown
75+
code?: unknown
76+
metadata?: { raw?: unknown; provider_name?: unknown } | null
77+
})
78+
: {}
79+
80+
const baseMessage =
81+
typeof error.message === 'string' && error.message.length > 0
82+
? error.message
83+
: JSON.stringify(errorValue)
84+
85+
const raw =
86+
typeof error.metadata?.raw === 'string' && error.metadata.raw.length > 0
87+
? error.metadata.raw
88+
: undefined
89+
const providerLabel =
90+
typeof error.metadata?.provider_name === 'string'
91+
? error.metadata.provider_name
92+
: 'Provider details'
93+
const truncatedRaw =
94+
raw !== undefined && raw.length > MAX_PROVIDER_DETAIL_LENGTH
95+
? raw.slice(0, MAX_PROVIDER_DETAIL_LENGTH) + '...'
96+
: raw
97+
const message =
98+
truncatedRaw !== undefined
99+
? `${baseMessage} [${providerLabel}: ${truncatedRaw}]`
100+
: baseMessage
101+
102+
// OpenRouter puts the upstream HTTP status in `code` as a number; some
103+
// providers send it as a digit string.
104+
const statusCode =
105+
typeof error.code === 'number' && Number.isInteger(error.code)
106+
? error.code
107+
: typeof error.code === 'string' && /^\d{3}$/.test(error.code)
108+
? Number(error.code)
109+
: undefined
110+
111+
return new APICallError({
112+
message,
113+
url,
114+
requestBodyValues,
115+
statusCode,
116+
responseBody: JSON.stringify({ error: { ...error, message } }),
117+
})
118+
}

0 commit comments

Comments
 (0)