Skip to content

Commit 62ca90d

Browse files
fix(gateway): surface in-stream errors from Zoo and Vercel AI gateways
Once a gateway response starts streaming the HTTP status is already 200, so upstream failures (e.g. provider rate limits) arrive as an in-band error chunk rather than a thrown HTTP error. Both handlers ignored these chunks, so the extension showed a generic "no response" instead of the real reason. - zoo-gateway: detect the error chunk and rebuild it into an Error carrying status/code so the existing classify/surface logic handles it (sign-in, add-credits, budget, etc.), and the upstream message reaches the user. - vercel-ai-gateway: detect the error chunk and throw the upstream message. - Add unit tests covering both handlers and the toGatewayStreamError mapping. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 25545e9 commit 62ca90d

4 files changed

Lines changed: 123 additions & 1 deletion

File tree

src/api/providers/__tests__/vercel-ai-gateway.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,28 @@ describe("VercelAiGatewayHandler", () => {
190190
})
191191
})
192192

193+
it("throws the upstream reason when an in-stream error chunk is received", async () => {
194+
mockCreate.mockImplementation(async () => ({
195+
[Symbol.asyncIterator]: async function* () {
196+
yield {
197+
error: {
198+
message: "Too many requests, please wait before trying again",
199+
code: 429,
200+
},
201+
}
202+
},
203+
}))
204+
205+
const handler = new VercelAiGatewayHandler(mockOptions)
206+
const stream = handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }])
207+
208+
await expect(async () => {
209+
for await (const _chunk of stream) {
210+
// drain
211+
}
212+
}).rejects.toThrow("Too many requests, please wait before trying again")
213+
})
214+
193215
it("uses correct temperature from options", async () => {
194216
const customTemp = 0.5
195217
const handler = new VercelAiGatewayHandler({

src/api/providers/__tests__/zoo-gateway.spec.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import OpenAI from "openai"
1919

2020
import { zooGatewayDefaultModelId, ZOO_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types"
2121

22-
import { ZooGatewayHandler, classifyGatewayApiError } from "../zoo-gateway"
22+
import { ZooGatewayHandler, classifyGatewayApiError, toGatewayStreamError } from "../zoo-gateway"
2323
import { ApiHandlerOptions } from "../../../shared/api"
2424
import { Package } from "../../../shared/package"
2525
import { clearZooCodeToken } from "../../../services/zoo-code-auth"
@@ -363,6 +363,48 @@ describe("ZooGatewayHandler", () => {
363363
},
364364
])
365365
})
366+
367+
it("throws the upstream reason when the gateway sends an in-stream error chunk", async () => {
368+
mockCreate.mockImplementation(async () => ({
369+
[Symbol.asyncIterator]: async function* () {
370+
yield {
371+
error: {
372+
message: "Too many requests, please wait before trying again",
373+
status: 429,
374+
code: "rate_limited",
375+
},
376+
}
377+
},
378+
}))
379+
380+
const handler = new ZooGatewayHandler(mockOptions)
381+
382+
await expect(drainCreateMessage(handler)).rejects.toThrow(
383+
"Too many requests, please wait before trying again",
384+
)
385+
})
386+
387+
it("surfaces the add-credits prompt when an in-stream error carries a budget code", async () => {
388+
mockCreate.mockImplementation(async () => ({
389+
[Symbol.asyncIterator]: async function* () {
390+
yield {
391+
error: {
392+
message: "Monthly budget exceeded",
393+
status: 429,
394+
code: "monthly_budget_exceeded",
395+
},
396+
}
397+
},
398+
}))
399+
400+
const handler = new ZooGatewayHandler(mockOptions)
401+
402+
await expect(drainCreateMessage(handler)).rejects.toThrow()
403+
expect(showErrorMessage).toHaveBeenCalledWith(
404+
"common:zooAuth.errors.budget_exceeded",
405+
"common:zooAuth.buttons.add_credits",
406+
)
407+
})
366408
})
367409

368410
describe("completePrompt", () => {
@@ -443,6 +485,32 @@ describe("ZooGatewayHandler", () => {
443485
})
444486
})
445487

488+
describe("toGatewayStreamError", () => {
489+
it("preserves the message, status, and code from the chunk", () => {
490+
const error = toGatewayStreamError({
491+
message: "rate limited",
492+
status: 429,
493+
code: "rate_limited",
494+
}) as Error & {
495+
status?: number
496+
code?: string
497+
}
498+
499+
expect(error).toBeInstanceOf(Error)
500+
expect(error.message).toBe("rate limited")
501+
expect(error.status).toBe(429)
502+
expect(error.code).toBe("rate_limited")
503+
})
504+
505+
it("falls back to a default message and leaves status/code undefined", () => {
506+
const error = toGatewayStreamError({}) as Error & { status?: number; code?: string }
507+
508+
expect(error.message).toBe("Zoo Gateway stream error")
509+
expect(error.status).toBeUndefined()
510+
expect(error.code).toBeUndefined()
511+
})
512+
})
513+
446514
describe("surfaceGatewayApiError", () => {
447515
it("clears the cached token and offers re-sign-in on 401", async () => {
448516
const handler = new ZooGatewayHandler(mockOptions)

src/api/providers/vercel-ai-gateway.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ export class VercelAiGatewayHandler extends RouterProvider implements SingleComp
6969
const completion = await this.client.chat.completions.create(body)
7070

7171
for await (const chunk of completion) {
72+
// Vercel AI Gateway reports mid-stream failures as an in-band error chunk
73+
// rather than throwing, so surface it instead of returning an empty response.
74+
if ("error" in chunk && chunk.error) {
75+
const raw = chunk.error as { message?: unknown }
76+
const message =
77+
typeof raw.message === "string" && raw.message.length > 0
78+
? raw.message
79+
: "Vercel AI Gateway stream error"
80+
throw new Error(message)
81+
}
82+
7283
const delta = chunk.choices[0]?.delta
7384
if (delta?.content) {
7485
yield {

src/api/providers/zoo-gateway.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,20 @@ function getApiErrorCode(error: unknown): string | undefined {
3737
return undefined
3838
}
3939

40+
// The gateway sends in-band stream errors as `{ message, status?, code? }`. Rebuild
41+
// them into an Error carrying status/code so the same classify/surface logic that
42+
// handles thrown HTTP errors applies to mid-stream failures too.
43+
// Exported for unit tests.
44+
export function toGatewayStreamError(raw: unknown): Error {
45+
const err = raw as { message?: unknown; status?: unknown; code?: unknown } | null
46+
const message =
47+
typeof err?.message === "string" && err.message.length > 0 ? err.message : "Zoo Gateway stream error"
48+
return Object.assign(new Error(message), {
49+
status: typeof err?.status === "number" ? err.status : undefined,
50+
code: typeof err?.code === "string" ? err.code : undefined,
51+
})
52+
}
53+
4054
function buildZooCodeSignInUrl(): string {
4155
const callbackUri = encodeURIComponent(
4256
`${vscode.env.uriScheme}://${Package.publisher}.${Package.name}/auth-callback`,
@@ -209,6 +223,13 @@ export class ZooGatewayHandler extends RouterProvider implements SingleCompletio
209223
})
210224

211225
for await (const chunk of completion) {
226+
// Once the gateway starts streaming the HTTP status is already 200, so it
227+
// reports upstream failures (e.g. provider rate limits) as an in-band error
228+
// chunk. Surface it so the user sees the real reason instead of an empty reply.
229+
if ("error" in chunk && chunk.error) {
230+
throw toGatewayStreamError(chunk.error)
231+
}
232+
212233
const delta = chunk.choices[0]?.delta
213234
if (delta?.content) {
214235
yield {

0 commit comments

Comments
 (0)