tweak: add new ContextOverflowError type (#12777)

This commit is contained in:
Aiden Cline
2026-02-08 23:54:01 -06:00
committed by GitHub
parent d40dffb854
commit 99ea1351ce
6 changed files with 350 additions and 126 deletions

View File

@@ -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,
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
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()

View File

@@ -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

View File

@@ -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" })

View File

@@ -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
}
}