diff --git a/packages/ai/src/provider-error.ts b/packages/ai/src/provider-error.ts index a1557193198..37b0f2a587a 100644 --- a/packages/ai/src/provider-error.ts +++ b/packages/ai/src/provider-error.ts @@ -16,7 +16,6 @@ import { const patterns = [ /prompt is too long/i, - /request_too_large/i, /input is too long for requested model/i, /exceeds the context window/i, /exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i, @@ -33,7 +32,6 @@ const patterns = [ /context window exceeds limit/i, /exceeded model token limit/i, /context[_ ]length[_ ]exceeded/i, - /request entity too large/i, /context length is only \d+ tokens/i, /input length.*exceeds.*context length/i, /prompt too long; exceeded (?:max )?context length/i, @@ -44,11 +42,15 @@ const patterns = [ /token limit exceeded/i, ] +const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i] + const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i] export const isContextOverflow = (message: string) => !exclusions.some((pattern) => pattern.test(message)) && - (patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)) + (patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message)) + +export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message)) export const isContextOverflowFailure = (failure: unknown) => failure instanceof LLMError @@ -100,6 +102,8 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso isContextOverflow(text)) ) return new InvalidRequestReason({ ...common, classification: "context-overflow" }) + if (input.status === 413 || isPayloadTooLarge(text)) + return new InvalidRequestReason({ ...common, classification: "payload-too-large" }) if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common) if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text))) return new QuotaExceededReason(common) @@ -142,12 +146,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso retryAfterMs: input.retryAfterMs, }) if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common) - if ( - input.status === 400 || - input.status === 404 || - input.status === 413 || - input.status === 422 - ) + if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422) return new InvalidRequestReason(common) return new UnknownProviderReason({ ...common, status: input.status }) } diff --git a/packages/ai/src/schema/errors.ts b/packages/ai/src/schema/errors.ts index 7b4cb96425b..0bb6bfe74d0 100644 --- a/packages/ai/src/schema/errors.ts +++ b/packages/ai/src/schema/errors.ts @@ -2,7 +2,7 @@ import { Schema } from "effect" import { Tool } from "@opencode-ai/schema/tool" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" -export const ProviderFailureClassification = Schema.Literal("context-overflow") +export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"]) export type ProviderFailureClassification = typeof ProviderFailureClassification.Type export class HttpRequestDetails extends Schema.Class("LLM.HttpRequestDetails")({ diff --git a/packages/ai/test/executor.test.ts b/packages/ai/test/executor.test.ts index 2a0056896ff..07119cfc5a2 100644 --- a/packages/ai/test/executor.test.ts +++ b/packages/ai/test/executor.test.ts @@ -85,14 +85,17 @@ describe("RequestExecutor", () => { ), ) - it.effect("does not classify generic HTTP 413 payload errors as context overflow", () => + it.effect("classifies generic HTTP 413 payload errors", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service const error = yield* executor.execute(request).pipe(Effect.flip) expectLLMError(error) - expect(error.reason).toMatchObject({ _tag: "InvalidRequest" }) - expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined() + expect(error.reason).toMatchObject({ + _tag: "InvalidRequest", + classification: "payload-too-large", + http: { response: { status: 413 } }, + }) }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), ) diff --git a/packages/ai/test/provider-error.test.ts b/packages/ai/test/provider-error.test.ts index 667009fa0aa..ca499c46c4a 100644 --- a/packages/ai/test/provider-error.test.ts +++ b/packages/ai/test/provider-error.test.ts @@ -6,7 +6,6 @@ describe("provider error classification", () => { test("classifies provider token limit messages as context overflow", () => { const messages = [ "tokens in request more than max tokens allowed", - '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', "Requested token count exceeds the model's maximum context length of 131072 tokens.", "Input length (265330) exceeds model's maximum context length (262144).", "Input length 131393 exceeds the maximum allowed input length of 131040 tokens.", @@ -19,6 +18,24 @@ describe("provider error classification", () => { expect(messages.every(isContextOverflow)).toBe(true) }) + test("classifies request size failures separately from context overflow", () => { + const failures = [ + classifyProviderFailure({ message: "request too large", status: 413 }), + classifyProviderFailure({ + message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', + status: 400, + }), + classifyProviderFailure({ message: "upstream request entity too large", status: 502 }), + ] + + expect(failures).toEqual( + failures.map((failure) => + expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }), + ), + ) + expect(isContextOverflow("413 status code (no body)")).toBe(false) + }) + test("does not classify rate limits as context overflow", () => { const messages = [ "Throttling error: Too many tokens, please wait before trying again.", @@ -59,9 +76,10 @@ describe("provider error classification", () => { }) test("classifies transient client statuses as provider internal", () => { - expect( - [408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag), - ).toEqual(["ProviderInternal", "ProviderInternal"]) + expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([ + "ProviderInternal", + "ProviderInternal", + ]) }) test("classifies nested provider codes when a top-level code is also present", () => { diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index f50b99567a2..b05cd55baf1 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -398,7 +398,7 @@ export type Endpoint5_26Output = readonly location?: Location.Ref | undefined readonly data: { readonly sessionID: Session.ID - readonly error: { readonly type: string; readonly message: string } + readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } } } | { @@ -524,7 +524,7 @@ export type Endpoint5_26Output = readonly data: { readonly sessionID: Session.ID readonly assistantMessageID: SessionMessage.ID - readonly error: { readonly type: string; readonly message: string } + readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined readonly tokens?: | { @@ -686,7 +686,7 @@ export type Endpoint5_26Output = readonly sessionID: Session.ID readonly assistantMessageID: SessionMessage.ID readonly callID: string - readonly error: { readonly type: string; readonly message: string } + readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } readonly content?: | readonly [ ( @@ -726,7 +726,7 @@ export type Endpoint5_26Output = readonly assistantMessageID: SessionMessage.ID readonly attempt: number readonly at: number - readonly error: { readonly type: string; readonly message: string } + readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } } } | { @@ -776,7 +776,7 @@ export type Endpoint5_26Output = readonly data: { readonly sessionID: Session.ID readonly reason: "auto" | "manual" - readonly error: { readonly type: string; readonly message: string } + readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined } readonly inputID?: SessionMessage.ID | undefined } } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index acb2d26fcfd..766e845ffd1 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string } export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null } -export type SessionStructuredError = { type: string; message: string } +export type SessionStructuredError = { type: string; message: string; status?: number } export type SessionMessageCompactionRunning = { type: "compaction" diff --git a/packages/core/src/session/to-session-error.ts b/packages/core/src/session/to-session-error.ts index d7ccb3dc7e9..771deeb6043 100644 --- a/packages/core/src/session/to-session-error.ts +++ b/packages/core/src/session/to-session-error.ts @@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof LLMError) { switch (cause.reason._tag) { case "RateLimit": - return { type: "provider.rate-limit", message: cause.reason.message } + return providerError("provider.rate-limit", cause.reason) case "Authentication": - return { type: "provider.auth", message: cause.reason.message } + return providerError("provider.auth", cause.reason) case "QuotaExceeded": - return { type: "provider.quota", message: cause.reason.message } + return providerError("provider.quota", cause.reason) case "ContentPolicy": - return { type: "provider.content-filter", message: cause.reason.message } + return providerError("provider.content-filter", cause.reason) case "Transport": - return { type: "provider.transport", message: cause.reason.message } + return providerError("provider.transport", cause.reason) case "ProviderInternal": - return { type: "provider.internal", message: cause.reason.message } + return providerError("provider.internal", cause.reason) case "InvalidProviderOutput": - return { type: "provider.invalid-output", message: cause.reason.message } + return providerError("provider.invalid-output", cause.reason) case "InvalidRequest": - return { type: "provider.invalid-request", message: cause.reason.message } + return providerError("provider.invalid-request", cause.reason) case "NoRoute": - return { type: "provider.no-route", message: cause.reason.message } + return providerError("provider.no-route", cause.reason) case "UnknownProvider": - return { type: "provider.unknown", message: cause.reason.message } + return providerError("provider.unknown", cause.reason) default: { const exhaustive: never = cause.reason return exhaustive @@ -58,3 +58,9 @@ export function toSessionError(cause: unknown): SessionError.Error { if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message } return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) } } + +function providerError(type: string, reason: LLMError["reason"]): SessionError.Error { + const status = + ("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined) + return { type, message: reason.message, ...(status === undefined ? {} : { status }) } +} diff --git a/packages/core/test/session-error.test.ts b/packages/core/test/session-error.test.ts index 0b0ed9d905b..9e2d18659a3 100644 --- a/packages/core/test/session-error.test.ts +++ b/packages/core/test/session-error.test.ts @@ -14,6 +14,9 @@ import { TransportReason, UnknownProviderReason, ToolFailure, + HttpContext, + HttpRequestDetails, + HttpResponseDetails, } from "@opencode-ai/ai" import { Permission } from "@opencode-ai/core/permission" import { Tool } from "@opencode-ai/schema/tool" @@ -71,6 +74,23 @@ describe("toSessionError", () => { }) }) + test("preserves provider HTTP status", () => { + const http = new HttpContext({ + request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }), + response: new HttpResponseDetails({ status: 413, headers: {} }), + }) + expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({ + type: "provider.invalid-request", + message: "too large", + status: 413, + }) + expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({ + type: "provider.internal", + message: "bad gateway", + status: 502, + }) + }) + test("retries only rate limits, provider-internal failures, and transport failures", () => { const eligible = [ llm(new RateLimitReason({ message: "rate" })), diff --git a/packages/schema/src/session-error.ts b/packages/schema/src/session-error.ts index b11efdbdeeb..279044ac89b 100644 --- a/packages/schema/src/session-error.ts +++ b/packages/schema/src/session-error.ts @@ -1,9 +1,11 @@ export * as SessionError from "./session-error.js" import { Schema } from "effect" +import { optional } from "./schema.js" export interface Error extends Schema.Schema.Type {} export const Error = Schema.Struct({ type: Schema.String, message: Schema.String, + status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional), }).annotate({ identifier: "Session.StructuredError" })