diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts new file mode 100644 index 000000000..2693df04f --- /dev/null +++ b/packages/opencode/src/provider/error.ts @@ -0,0 +1,191 @@ +import { APICallError } from "ai" +import { STATUS_CODES } from "http" +import { iife } from "@/util/iife" + +export namespace ProviderError { + // Adapted from overflow detection patterns in: + // https://github.com/badlogic/pi-mono/blob/main/packages/ai/src/utils/overflow.ts + const OVERFLOW_PATTERNS = [ + /prompt is too long/i, // Anthropic + /input is too long for requested model/i, // Amazon Bedrock + /exceeds the context window/i, // OpenAI (Completions + Responses API message text) + /input token count.*exceeds the maximum/i, // Google (Gemini) + /maximum prompt length is \d+/i, // xAI (Grok) + /reduce the length of the messages/i, // Groq + /maximum context length is \d+ tokens/i, // OpenRouter + /exceeds the limit of \d+/i, // GitHub Copilot + /exceeds the available context size/i, // llama.cpp server + /greater than the context length/i, // LM Studio + /context window exceeds limit/i, // MiniMax + /exceeded model token limit/i, // Kimi For Coding + /context[_ ]length[_ ]exceeded/i, // Generic fallback + /too many tokens/i, // Generic fallback + /token limit exceeded/i, // Generic fallback + ] + + function isOpenAiErrorRetryable(e: APICallError) { + const status = e.statusCode + if (!status) return e.isRetryable + // openai sometimes returns 404 for models that are actually available + return status === 404 || e.isRetryable + } + + // Providers not reliably handled in this function: + // - z.ai: can accept overflow silently (needs token-count/context-window checks) + function isOverflow(message: string) { + if (OVERFLOW_PATTERNS.some((p) => p.test(message))) return true + + // Providers/status patterns handled outside of regex list: + // - Cerebras: often returns "400 (no body)" / "413 (no body)" + // - Mistral: often returns "400 (no body)" / "413 (no body)" + return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) + } + + function error(providerID: string, error: APICallError) { + if (providerID.includes("github-copilot") && error.statusCode === 403) { + return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode." + } + + return error.message + } + + function message(providerID: string, e: APICallError) { + return iife(() => { + const msg = e.message + if (msg === "") { + if (e.responseBody) return e.responseBody + if (e.statusCode) { + const err = STATUS_CODES[e.statusCode] + if (err) return err + } + return "Unknown error" + } + + const transformed = error(providerID, e) + if (transformed !== msg) { + return transformed + } + if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) { + return msg + } + + try { + const body = JSON.parse(e.responseBody) + // try to extract common error message fields + const errMsg = body.message || body.error || body.error?.message + if (errMsg && typeof errMsg === "string") { + return `${msg}: ${errMsg}` + } + } catch {} + + return `${msg}: ${e.responseBody}` + }).trim() + } + + function json(input: unknown) { + if (typeof input === "string") { + try { + const result = JSON.parse(input) + if (result && typeof result === "object") return result + return undefined + } catch { + return undefined + } + } + if (typeof input === "object" && input !== null) { + return input + } + return undefined + } + + export type ParsedStreamError = + | { + type: "context_overflow" + message: string + responseBody: string + } + | { + type: "api_error" + message: string + isRetryable: false + responseBody: string + } + + export function parseStreamError(input: unknown): ParsedStreamError | undefined { + const body = json(input) + if (!body) return + + const responseBody = JSON.stringify(body) + if (body.type !== "error") return + + switch (body?.error?.code) { + case "context_length_exceeded": + return { + type: "context_overflow", + message: "Input exceeds context window of this model", + responseBody, + } + case "insufficient_quota": + return { + type: "api_error", + message: "Quota exceeded. Check your plan and billing details.", + isRetryable: false, + responseBody, + } + case "usage_not_included": + return { + type: "api_error", + message: "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", + isRetryable: false, + responseBody, + } + case "invalid_prompt": + return { + type: "api_error", + message: typeof body?.error?.message === "string" ? body?.error?.message : "Invalid prompt.", + isRetryable: false, + responseBody, + } + } + } + + export type ParsedAPICallError = + | { + type: "context_overflow" + message: string + responseBody?: string + } + | { + type: "api_error" + message: string + statusCode?: number + isRetryable: boolean + responseHeaders?: Record + responseBody?: string + metadata?: Record + } + + export function parseAPICallError(input: { providerID: string; error: APICallError }): ParsedAPICallError { + const m = message(input.providerID, input.error) + if (isOverflow(m)) { + return { + type: "context_overflow", + message: m, + responseBody: input.error.responseBody, + } + } + + const metadata = input.error.url ? { url: input.error.url } : undefined + return { + type: "api_error", + message: m, + statusCode: input.error.statusCode, + isRetryable: input.providerID.startsWith("openai") + ? isOpenAiErrorRetryable(input.error) + : input.error.isRetryable, + responseHeaders: input.error.responseHeaders, + responseBody: input.error.responseBody, + metadata, + } + } +} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4e9467523..01291491d 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1,4 +1,4 @@ -import type { APICallError, ModelMessage } from "ai" +import type { ModelMessage } from "ai" import { mergeDeep, unique } from "remeda" import type { JSONSchema7 } from "@ai-sdk/provider" import type { JSONSchema } from "zod/v4/core" @@ -824,13 +824,4 @@ export namespace ProviderTransform { return schema as JSONSchema7 } - - export function error(providerID: string, error: APICallError) { - let message = error.message - if (providerID.includes("github-copilot") && error.statusCode === 403) { - return "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode." - } - - return message - } } diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 3119c2bce..e45bfc772 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -7,8 +7,7 @@ import { LSP } from "../lsp" import { Snapshot } from "@/snapshot" import { fn } from "@/util/fn" import { Storage } from "@/storage/storage" -import { ProviderTransform } from "@/provider/transform" -import { STATUS_CODES } from "http" +import { ProviderError } from "@/provider/error" import { iife } from "@/util/iife" import { type SystemError } from "bun" import type { Provider } from "@/provider/provider" @@ -35,6 +34,10 @@ export namespace MessageV2 { }), ) export type APIError = z.infer + export const ContextOverflowError = NamedError.create( + "ContextOverflowError", + z.object({ message: z.string(), responseBody: z.string().optional() }), + ) const PartBase = z.object({ id: z.string(), @@ -361,6 +364,7 @@ export namespace MessageV2 { NamedError.Unknown.Schema, OutputLengthError.Schema, AbortedError.Schema, + ContextOverflowError.Schema, APIError.Schema, ]) .optional(), @@ -711,13 +715,6 @@ export namespace MessageV2 { return result } - const isOpenAiErrorRetryable = (e: APICallError) => { - const status = e.statusCode - if (!status) return e.isRetryable - // openai sometimes returns 404 for models that are actually available - return status === 404 || e.isRetryable - } - export function fromError(e: unknown, ctx: { providerID: string }) { switch (true) { case e instanceof DOMException && e.name === "AbortError": @@ -751,45 +748,28 @@ export namespace MessageV2 { { cause: e }, ).toObject() case APICallError.isInstance(e): - const message = iife(() => { - let msg = e.message - if (msg === "") { - if (e.responseBody) return e.responseBody - if (e.statusCode) { - const err = STATUS_CODES[e.statusCode] - if (err) return err - } - return "Unknown error" - } - const transformed = ProviderTransform.error(ctx.providerID, e) - if (transformed !== msg) { - return transformed - } - if (!e.responseBody || (e.statusCode && msg !== STATUS_CODES[e.statusCode])) { - return msg - } + const parsed = ProviderError.parseAPICallError({ + providerID: ctx.providerID, + error: e, + }) + if (parsed.type === "context_overflow") { + return new MessageV2.ContextOverflowError( + { + message: parsed.message, + responseBody: parsed.responseBody, + }, + { cause: e }, + ).toObject() + } - try { - const body = JSON.parse(e.responseBody) - // try to extract common error message fields - const errMsg = body.message || body.error || body.error?.message - if (errMsg && typeof errMsg === "string") { - return `${msg}: ${errMsg}` - } - } catch {} - - return `${msg}: ${e.responseBody}` - }).trim() - - const metadata = e.url ? { url: e.url } : undefined return new MessageV2.APIError( { - message, - statusCode: e.statusCode, - isRetryable: ctx.providerID.startsWith("openai") ? isOpenAiErrorRetryable(e) : e.isRetryable, - responseHeaders: e.responseHeaders, - responseBody: e.responseBody, - metadata, + message: parsed.message, + statusCode: parsed.statusCode, + isRetryable: parsed.isRetryable, + responseHeaders: parsed.responseHeaders, + responseBody: parsed.responseBody, + metadata: parsed.metadata, }, { cause: e }, ).toObject() @@ -797,72 +777,27 @@ export namespace MessageV2 { return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject() default: try { - const json = iife(() => { - if (typeof e === "string") { - try { - return JSON.parse(e) - } catch { - return undefined - } - } - - if (typeof e === "object" && e !== null) { - return e - } - return undefined - }) - if (json) { - const responseBody = JSON.stringify(json) - // Handle Responses API mid stream style errors - if (json?.type === "error") { - switch (json?.error?.code) { - case "context_length_exceeded": - return new MessageV2.APIError( - { - message: "Input exceeds context window of this model", - isRetryable: false, - responseBody, - }, - { - cause: e, - }, - ).toObject() - case "insufficient_quota": - return new MessageV2.APIError( - { - message: "Quota exceeded. Check your plan and billing details.", - isRetryable: false, - responseBody, - }, - { - cause: e, - }, - ).toObject() - case "usage_not_included": - return new MessageV2.APIError( - { - message: - "To use Codex with your ChatGPT plan, upgrade to Plus: https://chatgpt.com/explore/plus.", - isRetryable: false, - responseBody, - }, - { - cause: e, - }, - ).toObject() - case "invalid_prompt": - return new MessageV2.APIError( - { - message: json?.error?.message || "Invalid prompt.", - isRetryable: false, - responseBody, - }, - { - cause: e, - }, - ).toObject() - } + const parsed = ProviderError.parseStreamError(e) + if (parsed) { + if (parsed.type === "context_overflow") { + return new MessageV2.ContextOverflowError( + { + message: parsed.message, + responseBody: parsed.responseBody, + }, + { cause: e }, + ).toObject() } + return new MessageV2.APIError( + { + message: parsed.message, + isRetryable: parsed.isRetryable, + responseBody: parsed.responseBody, + }, + { + cause: e, + }, + ).toObject() } } catch {} return new NamedError.Unknown({ message: JSON.stringify(e) }, { cause: e }).toObject() diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index a71a6a382..0d9a865b1 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -59,6 +59,9 @@ export namespace SessionRetry { } export function retryable(error: ReturnType) { + // DO NOT retry context overflow errors + if (MessageV2.ContextOverflowError.isInstance(error)) return undefined + if (MessageV2.APIError.isInstance(error)) { if (!error.data.isRetryable) return undefined return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts index 39c58bb6e..c043754bd 100644 --- a/packages/opencode/test/session/message-v2.test.ts +++ b/packages/opencode/test/session/message-v2.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { APICallError } from "ai" import { MessageV2 } from "../../src/session/message-v2" import type { Provider } from "../../src/provider/provider" @@ -786,12 +787,26 @@ describe("session.message-v2.toModelMessage", () => { }) describe("session.message-v2.fromError", () => { + test("serializes context_length_exceeded as ContextOverflowError", () => { + const input = { + type: "error", + error: { + code: "context_length_exceeded", + }, + } + const result = MessageV2.fromError(input, { providerID: "test" }) + + expect(result).toStrictEqual({ + name: "ContextOverflowError", + data: { + message: "Input exceeds context window of this model", + responseBody: JSON.stringify(input), + }, + }) + }) + test("serializes response error codes", () => { const cases = [ - { - code: "context_length_exceeded", - message: "Input exceeds context window of this model", - }, { code: "insufficient_quota", message: "Quota exceeded. Check your plan and billing details.", @@ -827,6 +842,75 @@ describe("session.message-v2.fromError", () => { }) }) + test("maps github-copilot 403 to reauth guidance", () => { + const error = new APICallError({ + message: "forbidden", + url: "https://api.githubcopilot.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 403, + responseHeaders: { "content-type": "application/json" }, + responseBody: '{"error":"forbidden"}', + isRetryable: false, + }) + + const result = MessageV2.fromError(error, { providerID: "github-copilot" }) + + expect(result).toStrictEqual({ + name: "APIError", + data: { + message: + "Please reauthenticate with the copilot provider to ensure your credentials work properly with OpenCode.", + statusCode: 403, + isRetryable: false, + responseHeaders: { "content-type": "application/json" }, + responseBody: '{"error":"forbidden"}', + metadata: { + url: "https://api.githubcopilot.com/v1/chat/completions", + }, + }, + }) + }) + + test("detects context overflow from APICallError provider messages", () => { + const cases = [ + "prompt is too long: 213462 tokens > 200000 maximum", + "Your input exceeds the context window of this model", + "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)", + "Please reduce the length of the messages or completion", + "400 status code (no body)", + "413 status code (no body)", + ] + + cases.forEach((message) => { + const error = new APICallError({ + message, + url: "https://example.com", + requestBodyValues: {}, + statusCode: 400, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID: "test" }) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(true) + }) + }) + + test("does not classify 429 no body as context overflow", () => { + const result = MessageV2.fromError( + new APICallError({ + message: "429 status code (no body)", + url: "https://example.com", + requestBodyValues: {}, + statusCode: 429, + responseHeaders: { "content-type": "application/json" }, + isRetryable: false, + }), + { providerID: "test" }, + ) + expect(MessageV2.ContextOverflowError.isInstance(result)).toBe(false) + expect(MessageV2.APIError.isInstance(result)).toBe(true) + }) + test("serializes unknown inputs", () => { const result = MessageV2.fromError(123, { providerID: "test" }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d72c37a28..9543e5b57 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -152,6 +152,14 @@ export type MessageAbortedError = { } } +export type ContextOverflowError = { + name: "ContextOverflowError" + data: { + message: string + responseBody?: string + } +} + export type ApiError = { name: "APIError" data: { @@ -176,7 +184,13 @@ export type AssistantMessage = { created: number completed?: number } - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | ContextOverflowError + | ApiError parentID: string modelID: string providerID: string @@ -820,7 +834,13 @@ export type EventSessionError = { type: "session.error" properties: { sessionID?: string - error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError + error?: + | ProviderAuthError + | UnknownError + | MessageOutputLengthError + | MessageAbortedError + | ContextOverflowError + | ApiError } }