summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAiden Cline <[email protected]>2026-02-08 23:54:01 -0600
committerGitHub <[email protected]>2026-02-08 23:54:01 -0600
commit99ea1351ce701e9b186ab9f76e84a7848869a829 (patch)
tree0137d97d0c85d176e768748878fd9ea10b5ed15c
parentd40dffb854b74af8fc2695018d8523ef152278de (diff)
downloadopencode-99ea1351ce701e9b186ab9f76e84a7848869a829.tar.gz
opencode-99ea1351ce701e9b186ab9f76e84a7848869a829.zip
tweak: add new ContextOverflowError type (#12777)
-rw-r--r--packages/opencode/src/provider/error.ts191
-rw-r--r--packages/opencode/src/provider/transform.ts11
-rw-r--r--packages/opencode/src/session/message-v2.ts155
-rw-r--r--packages/opencode/src/session/retry.ts3
-rw-r--r--packages/opencode/test/session/message-v2.test.ts90
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts24
6 files changed, 349 insertions, 125 deletions
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<string, string>
+ responseBody?: string
+ metadata?: Record<string, string>
+ }
+
+ 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<typeof APIError.Schema>
+ 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
- }
-
- 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 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()
+ }
- 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<NamedError["toObject"]>) {
+ // 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 response error codes", () => {
- const cases = [
- {
+ 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: "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
}
}