Просмотр исходного кода

tweak: add new ContextOverflowError type (#12777)

Aiden Cline 1 неделя назад
Родитель
Сommit
99ea1351ce

+ 191 - 0
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,
+    }
+  }
+}

+ 1 - 10
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
-  }
 }

+ 45 - 110
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()

+ 3 - 0
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

+ 87 - 3
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" })
 

+ 22 - 2
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
   }
 }