Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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.
"@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
82 changes: 82 additions & 0 deletions packages/opencode/src/kilocode/provider/error.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,87 @@
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()]).nullish(),
message: z.string().nullish(),
})

// 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; checked before `bare` so a
// nested `error` record wins over bare top-level fields and gateway
// wrappers keep the specific inner code
const wrapper = z.looseObject({
type: z.undefined().optional(),
error: payload,
})

// Bare error object
const bare = z.looseObject({
type: z.undefined().optional(),
code: z.union([z.string(), z.number(), z.null()]),
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
// Keep free-form message matching narrow: Session.retryable only applied
// rate-limit phrases to prose; the wider pattern above is for structured
// code/type fields only
const RETRYABLE_TEXT = /rate increased too quickly|rate.?limit|too.?many.?requests/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_TEXT.test(message)
}

/**
* 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.
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
168 changes: 168 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,174 @@ 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.
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("normalizes error objects with explicit null fields", () => {
const payload = {
error: {
message: "The server had an error while processing your request",
type: "server_error",
param: null,
code: 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(payload.error.message)
expect(result.data.isRetryable).toBe(true)
})

test("normalizes response.failed frames with a null error code", () => {
const payload = {
type: "response.failed",
response: { error: { code: null, message: "mid-stream failure" }, 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("mid-stream failure")
expect(result.data.isRetryable).toBe(false)
})

test("does not retry terminal errors that merely mention availability", () => {
const payload = { code: "invalid_request", message: "The model is unavailable in your region" }
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(false)
})

test("retries hyphenated rate-limit prose", () => {
const payload = {
code: "upstream_error",
message: "openai/gpt-5.6-terra-pro is temporarily rate-limited upstream. Please retry shortly.",
}
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