Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/responses-stream-error-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---

Show the provider's actual error message when an OpenAI or Azure Responses API stream fails (for example an upstream rate limit) instead of a generic retry notice.
134 changes: 100 additions & 34 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
"drizzle-kit": "1.0.0-rc.2",
"drizzle-orm": "1.0.0-rc.2",
"effect": "4.0.0-beta.74",
"ai": "6.0.168",
"ai": "6.0.235",
"cross-spawn": "7.0.6",
"hono": "4.12.12",
"hono-openapi": "1.1.2",
Expand Down
10 changes: 5 additions & 5 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,20 +85,20 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.93",
"@ai-sdk/cerebras": "2.0.54",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/gateway": "3.0.157",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.88",
"@ai-sdk/openai-compatible": "2.0.48",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/provider": "3.0.14",
"@ai-sdk/provider-utils": "4.0.40",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
Expand Down
4 changes: 2 additions & 2 deletions packages/kilo-gateway/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.88",
"@ai-sdk/openai-compatible": "2.0.48",
"@ai-sdk/mistral": "3.0.27",
"@openrouter/ai-sdk-provider": "2.9.0",
Expand Down
10 changes: 5 additions & 5 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,20 @@
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/amazon-bedrock": "4.0.112",
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/azure": "3.0.49",
"@ai-sdk/azure": "3.0.93",
"@ai-sdk/cerebras": "2.0.54",
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/deepinfra": "2.0.41",
"@ai-sdk/gateway": "3.0.104",
"@ai-sdk/gateway": "3.0.157",
"@ai-sdk/google": "3.0.73",
"@ai-sdk/google-vertex": "4.0.128",
"@ai-sdk/groq": "3.0.31",
"@ai-sdk/mistral": "3.0.27",
"@ai-sdk/openai": "3.0.53",
"@ai-sdk/openai": "3.0.88",
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
"@ai-sdk/openai-compatible": "2.0.48",
"@ai-sdk/perplexity": "3.0.26",
"@ai-sdk/provider": "3.0.8",
"@ai-sdk/provider-utils": "4.0.23",
"@ai-sdk/provider": "3.0.14",
"@ai-sdk/provider-utils": "4.0.40",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.102",
Expand Down
77 changes: 77 additions & 0 deletions packages/opencode/src/kilocode/provider/error.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,82 @@
import type { APICallError } from "ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { isRecord } from "@/util/record"
import { z } from "zod"

export type Frame = {
type?: unknown
error?: Record<string, unknown>
} & Record<string, unknown>

const payload = z.looseObject({
code: z.union([z.string(), z.number()]).optional(),
message: z.string().optional(),
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
})

// OpenAI Responses API terminal frame forwarded by @ai-sdk/openai >= 3.0.82
const terminal = z.looseObject({
type: z.literal("response.failed"),
response: z.looseObject({ error: payload }),
})

// Envelope-less chat-completions wrapper
const wrapper = z.looseObject({
type: z.undefined().optional(),
error: payload,
})

// Bare error object; a nested `error` record wins over bare top-level
// fields so gateway wrappers keep the specific inner code
const bare = z.looseObject({
type: z.undefined().optional(),
code: z.union([z.string(), z.number()]),
message: z.string(),
})

/**
* Normalize provider stream error frames that arrive without the
* `{ type: "error" }` envelope expected by ProviderError.parseStreamError.
*/
export function frame(body: unknown): Frame {
const failed = terminal.safeParse(body)
if (failed.success) return { type: "error", error: failed.data.response.error }
const wrapped = wrapper.safeParse(body)
if (wrapped.success) return { ...wrapped.data, type: "error" }
const direct = bare.safeParse(body)
if (direct.success) return { ...direct.data, type: "error", error: direct.data }
if (!isRecord(body)) return {}
return { ...body, error: isRecord(body.error) ? body.error : undefined }
}

const RETRYABLE = /rate.?limit|too.?many.?requests|rate increased too quickly|exhausted|overload|server|unavailable|timeout/i

// Must stay at least as permissive as the Session.retryable heuristics that
// applied when these frames still surfaced as NamedError.Unknown, or
// previously-retried provider errors silently become terminal
function retryable(error: Frame["error"], message: string) {
const code = error?.code
const numeric = typeof code === "number" ? code : typeof code === "string" && code.trim() !== "" ? Number(code) : NaN
if (!Number.isNaN(numeric)) {
if (numeric === 429 || (numeric >= 500 && numeric < 600)) return true
} else if (typeof code === "string" && RETRYABLE.test(code)) {
return true
}
const type = error?.type
if (typeof type === "string" && RETRYABLE.test(type)) return true
return RETRYABLE.test(message)
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
}

/**
* Terminal handler for normalized frames whose error code is not listed in
* ProviderError.parseStreamError: surface the provider message instead of
* falling back to a raw JSON dump. Retryable only for rate-limit and
* 5xx-style signals in the code, type, or message.
*/
export function fallback(body: Frame, responseBody: string) {
const message = body.error?.message
if (typeof message !== "string" || !message.trim()) return
return { type: "api_error" as const, message, isRetryable: retryable(body.error, message), responseBody }
}

const AUTH_ERROR =
"Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project."
Expand Down
10 changes: 7 additions & 3 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,13 @@ export type ParsedStreamError =

export function parseStreamError(input: unknown): ParsedStreamError | undefined {
const raw = json(input)
const body = typeof raw?.message === "string" ? (json(raw.message) ?? raw) : raw
if (!body) return
// kilocode_change start - unwrap response.failed frames and bare provider error objects before the envelope gate
const original = typeof raw?.message === "string" ? (json(raw.message) ?? raw) : raw
if (!original) return

const responseBody = JSON.stringify(body)
const responseBody = JSON.stringify(original)
const body = KiloError.frame(original)
// kilocode_change end
if (body.type !== "error") return

switch (body?.error?.code) {
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -164,6 +167,7 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
responseBody,
}
}
return KiloError.fallback(body, responseBody) // kilocode_change - render unlisted provider error codes as clean messages
}

export type ParsedAPICallError =
Expand Down
117 changes: 117 additions & 0 deletions packages/opencode/test/kilocode/provider/error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,123 @@ describe("provider stream errors", () => {
})
})

describe("responses api terminal frames", () => {
test("surfaces the provider message from a response.failed frame", () => {
const payload = {
type: "response.failed",
sequence_number: 8,
response: {
error: {
code: "rate_limit_exceeded",
message:
"openai/gpt-5.6-terra-pro is temporarily rate-limited upstream. Please retry shortly, or add your own key to accumulate your rate limits: https://openrouter.ai/settings/integrations",
},
incomplete_details: null,
service_tier: "auto",
},
}
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.message).toBe(payload.response.error.message)
expect(result.data.isRetryable).toBe(true)
expect(result.data.responseBody).toBe(JSON.stringify(payload))
})

test("unwraps bare chat-completions error objects", () => {
const payload = { code: "rate_limit_exceeded", message: "Try again in 30 seconds." }
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.message).toBe(payload.message)
expect(result.data.isRetryable).toBe(true)
})

test("unwraps envelope-less error wrappers", () => {
const payload = { error: { code: "rate_limit_exceeded", message: "Try again in 30 seconds.", type: "tokens" } }
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.message).toBe(payload.error.message)
expect(result.data.isRetryable).toBe(true)
})

test("prefers a nested error record over bare top-level fields", () => {
const payload = {
code: 429,
message: "gateway wrapper",
error: { code: "rate_limit_exceeded", message: "inner provider detail" },
}
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.message).toBe(payload.error.message)
expect(result.data.isRetryable).toBe(true)
})

test("surfaces messages for unlisted error codes", () => {
const payload = {
type: "response.failed",
response: { error: { code: "model_not_found", message: "no such model" }, incomplete_details: null },
}
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.message).toBe("no such model")
expect(result.data.isRetryable).toBe(false)
})

test("marks numeric 429 and 5xx error codes retryable", () => {
Comment thread
chrarnoldus marked this conversation as resolved.
Resolved
for (const payload of [
{ code: 429, message: "too many requests" },
{ code: 503, message: "service unavailable" },
]) {
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.message).toBe(payload.message)
expect(result.data.isRetryable).toBe(true)
}
})

test("retries exhausted and anthropic rate-limit shapes like the old heuristics", () => {
const exhausted = { code: "resource_exhausted", message: "quota exceeded for the day" }
const anthropic = {
type: "error",
error: { type: "rate_limit_error", message: "request has exceeded your per-minute rate limit" },
}
for (const payload of [exhausted, anthropic]) {
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
}
})

test("marks numeric-string error codes retryable", () => {
const payload = { code: "429", message: "slow down" }
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(true)
if (!MessageV2.APIError.isInstance(result)) throw new Error("expected APIError")
expect(result.data.isRetryable).toBe(true)
})

test("ignores response.failed frames without an error payload", () => {
const payload = { type: "response.failed", response: { error: null, incomplete_details: null } }
const result = MessageV2.fromError(payload, { providerID: ProviderV2.ID.make("openai") })

expect(MessageV2.APIError.isInstance(result)).toBe(false)
})
})

describe("Google Gemini authentication errors", () => {
test("explains how to troubleshoot the rejected API key", () => {
const error = apiError(googleAuthError, "ACCESS_TOKEN_TYPE_UNSUPPORTED")
Expand Down
Loading