diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index 77058b72966..90fd9cdfa88 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -165,13 +165,15 @@ export const make = (dependencies: Dependencies) => { readonly entries: readonly Entry[] readonly model: Model readonly request: LLMRequest + readonly trigger?: "threshold" | "overflow" }) { const context = input.model.route.defaults.limits?.context - if (!config.auto || context === undefined || context <= 0) return false + if ((!config.auto && input.trigger !== "overflow") || context === undefined || context <= 0) return false const output = input.request.generation?.maxTokens ?? input.model.route.defaults.limits?.output ?? 0 if ( + input.trigger !== "overflow" && estimate({ system: input.request.system, messages: input.request.messages, tools: input.request.tools }) <= - context - Math.max(output, config.buffer) + context - Math.max(output, config.buffer) ) return false diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts index 031cab1d309..8b417c73b00 100644 --- a/packages/core/src/session/runner/llm.ts +++ b/packages/core/src/session/runner/llm.ts @@ -1,4 +1,4 @@ -import { LLM, LLMClient, LLMError, LLMEvent, SystemPart } from "@opencode-ai/llm" +import { LLM, LLMClient, LLMError, LLMEvent, SystemPart, type ProviderErrorEvent } from "@opencode-ai/llm" import { Cause, DateTime, Effect, FiberSet, Layer, Schema, Semaphore, Stream } from "effect" import { AgentV2 } from "../../agent" import { Config } from "../../config" @@ -131,7 +131,10 @@ export const layer = Layer.effect( cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError) class RetryTurn extends Error { - constructor(readonly promotion: SessionInput.Delivery | undefined) { + constructor( + readonly promotion: SessionInput.Delivery | undefined, + readonly consumeOverflowRetry = false, + ) { super() } } @@ -149,6 +152,7 @@ export const layer = Layer.effect( const runTurnAttempt = Effect.fn("SessionRunner.runTurn")(function* ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, + overflowRetryAvailable: boolean, ) { const session = yield* getSession(sessionID) if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID) @@ -208,11 +212,19 @@ export const layer = Layer.effect( }) const withPublication = Semaphore.makeUnsafe(1).withPermit const publish = (event: LLMEvent) => withPublication(publisher.publish(event)) + let overflowFailure: ProviderErrorEvent | undefined if (!(yield* SessionContextEpoch.current(db, session.id, agent.id, system.revision))) return yield* Effect.die(new RetryTurn(undefined)) const providerStream = llm.stream(request).pipe( Stream.runForEach((event) => Effect.gen(function* () { + if (overflowFailure || publisher.hasProviderError()) return + if (event.type === "provider-error") { + if (event.classification === "context-overflow" && !publisher.hasAssistantStarted()) { + overflowFailure = event + return + } + } yield* publish(event) if (event.type !== "tool-call" || event.providerExecuted) return needsContinuation = true @@ -251,6 +263,14 @@ export const layer = Layer.effect( if (reason.error instanceof LLMError) llmFailure = reason.error } } + const overflow = + overflowFailure !== undefined || + (llmFailure?.reason._tag === "InvalidRequest" && llmFailure.reason.classification === "context-overflow") + if (overflowRetryAvailable && overflow && !publisher.hasAssistantStarted()) { + const compacted = yield* compact({ sessionID: session.id, entries, model, request, trigger: "overflow" }) + if (compacted) return yield* Effect.die(new RetryTurn(undefined, true)) + } + if (overflowFailure) yield* withPublication(publisher.publish(overflowFailure)) if (llmFailure && !publisher.hasProviderError()) { yield* withPublication(publisher.failUnsettledTools("Provider did not return a tool result", true)) yield* withPublication( @@ -289,11 +309,16 @@ export const layer = Layer.effect( const runTurn: ( sessionID: SessionSchema.ID, promotion: SessionInput.Delivery | undefined, - ) => Effect.Effect = (sessionID, promotion) => - runTurnAttempt(sessionID, promotion).pipe( + overflowRetryAvailable?: boolean, + ) => Effect.Effect = (sessionID, promotion, overflowRetryAvailable = true) => + runTurnAttempt(sessionID, promotion, overflowRetryAvailable).pipe( Effect.catchDefect((defect) => defect instanceof RetryTurn - ? Effect.yieldNow.pipe(Effect.andThen(runTurn(sessionID, defect.promotion))) + ? Effect.yieldNow.pipe( + Effect.andThen( + runTurn(sessionID, defect.promotion, defect.consumeOverflowRetry ? false : overflowRetryAvailable), + ), + ) : Effect.die(defect), ), ) diff --git a/packages/core/src/session/runner/publish-llm-event.ts b/packages/core/src/session/runner/publish-llm-event.ts index 034018fd12e..4156a78535b 100644 --- a/packages/core/src/session/runner/publish-llm-event.ts +++ b/packages/core/src/session/runner/publish-llm-event.ts @@ -165,7 +165,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const startToolInput = Effect.fnUntraced(function* (event: { readonly id: string; readonly name: string }) { if (tools.has(event.id)) return yield* Effect.die(`Duplicate tool input start: ${event.id}`) - const assistantMessageID = yield* currentAssistantMessageID() + const assistantMessageID = yield* startAssistant() tools.set(event.id, { assistantMessageID, name: event.name, @@ -221,7 +221,6 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) const publish = Effect.fn("SessionRunner.publishLLMEvent")(function* (event: LLMEvent) { switch (event.type) { case "step-start": - yield* startAssistant() return case "text-start": yield* text.start(event.id) @@ -377,7 +376,7 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) yield* events.publish(SessionEvent.Step.Ended, { sessionID: input.sessionID, timestamp: yield* timestamp, - assistantMessageID: yield* currentAssistantMessageID(), + assistantMessageID: yield* startAssistant(), finish: event.reason, cost: 0, tokens: tokens(event.usage), @@ -398,5 +397,12 @@ export const createLLMEventPublisher = (events: EventV2.Interface, input: Input) } }) - return { publish, flush, failUnsettledTools, hasProviderError: () => providerFailed, startAssistant } + return { + publish, + flush, + failUnsettledTools, + hasAssistantStarted: () => assistantMessageID !== undefined, + hasProviderError: () => providerFailed, + startAssistant, + } } diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index b2f6605008a..bb65a968935 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -6,6 +6,7 @@ import { Model, Tool, TransportReason, + InvalidRequestReason, type LLMClientShape, type LLMRequest, } from "@opencode-ai/llm" @@ -103,6 +104,11 @@ const compactModel = Model.make({ provider: "fake", route: OpenAIChat.route.with({ limits: { context: 4_000, output: 50 } }), }) +const recoveryModel = Model.make({ + id: "recovery", + provider: "fake", + route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }), +}) const authorizations: ToolRegistry.AuthorizeInput[] = [] const executions: string[] = [] const permission = Layer.succeed( @@ -1456,6 +1462,119 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("forces one compaction and retries after provider context overflow", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents + yield* session.prompt({ + sessionID, + prompt: new Prompt({ text: "Earlier question ".repeat(700) }), + resume: false, + }) + yield* session.resume(sessionID) + + currentModel = recoveryModel + requests.length = 0 + responses = [ + [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), + ], + fragmentFixture("text", "text-summary", ["## Goal\n- Recover overflow"]).completeEvents, + fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + ] + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(3) + expect(userTexts(requests[1])[0]).toContain("## Goal") + expect(userTexts(requests[2])[0]).toContain("\n## Goal\n- Recover overflow\n") + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "compaction", summary: "## Goal\n- Recover overflow" }, + { type: "assistant", finish: "stop" }, + ]) + yield* replaySessionProjection(sessionID) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "compaction" }, + { type: "assistant", finish: "stop" }, + ]) + }), + ) + + it.effect("persists a second context overflow after one recovery", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents + yield* session.prompt({ + sessionID, + prompt: new Prompt({ text: "Earlier question ".repeat(700) }), + resume: false, + }) + yield* session.resume(sessionID) + + currentModel = recoveryModel + requests.length = 0 + const overflow = () => [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), + ] + responses = [ + overflow(), + fragmentFixture("text", "text-summary", ["## Goal\n- Recover once"]).completeEvents, + overflow(), + ] + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(3) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "compaction" }, + { type: "assistant", finish: "error", error: { message: "prompt too long" } }, + ]) + }), + ) + + it.effect("recovers once from a raw context overflow failure", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents + yield* session.prompt({ + sessionID, + prompt: new Prompt({ text: "Earlier question ".repeat(700) }), + resume: false, + }) + yield* session.resume(sessionID) + + currentModel = recoveryModel + requests.length = 0 + responseStream = Stream.fail( + new LLMError({ + module: "test", + method: "stream", + reason: new InvalidRequestReason({ + message: "prompt too long", + classification: "context-overflow", + }), + }), + ) + responses = [ + fragmentFixture("text", "text-summary", ["## Goal\n- Recover raw overflow"]).completeEvents, + fragmentFixture("text", "text-final", ["Recovered"]).completeEvents, + ] + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Continue" }), resume: false }) + yield* session.resume(sessionID) + + expect(requests).toHaveLength(3) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "compaction", summary: "## Goal\n- Recover raw overflow" }, + { type: "assistant", finish: "stop" }, + ]) + }), + ) + it.effect("preserves effective System updates while compaction replacement is blocked", () => Effect.gen(function* () { yield* setup @@ -3108,6 +3227,35 @@ describe("SessionRunnerLLM", () => { }), ) + it.effect("does not recover context overflow after durable assistant output", () => + Effect.gen(function* () { + yield* setup + const session = yield* SessionV2.Service + yield* session.prompt({ sessionID, prompt: new Prompt({ text: "Fail after output" }), resume: false }) + + requests.length = 0 + response = [ + LLMEvent.stepStart({ index: 0 }), + LLMEvent.textStart({ id: "text-partial" }), + LLMEvent.textDelta({ id: "text-partial", text: "Partial" }), + LLMEvent.textEnd({ id: "text-partial" }), + LLMEvent.providerError({ message: "prompt too long", classification: "context-overflow" }), + ] + yield* session.resume(sessionID) + + expect(requests).toHaveLength(1) + expect(yield* session.context(sessionID)).toMatchObject([ + { type: "user", text: "Fail after output" }, + { + type: "assistant", + finish: "error", + error: { message: "prompt too long" }, + content: [{ type: "text", text: "Partial" }], + }, + ]) + }), + ) + it.effect("projects raw provider stream failures as terminal assistant step failures", () => Effect.gen(function* () { yield* setup diff --git a/packages/llm/src/index.ts b/packages/llm/src/index.ts index b71bd6f7b5e..d22ce4f1108 100644 --- a/packages/llm/src/index.ts +++ b/packages/llm/src/index.ts @@ -1,6 +1,7 @@ export { LLMClient } from "./route/client" export { Auth } from "./route/auth" export { Provider } from "./provider" +export { isContextOverflow } from "./provider-error" export type { RouteModelInput, RouteRoutedModelInput, diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index d543e659ca6..6a1e17cd80f 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -18,6 +18,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { isContextOverflow } from "../provider-error" import * as Cache from "./utils/cache" import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" @@ -786,7 +787,12 @@ const providerErrorMessage = (event: AnthropicEvent): string => { const onError = (state: ParserState, event: AnthropicEvent): StepResult => [ state, - [LLMEvent.providerError({ message: providerErrorMessage(event) })], + [ + LLMEvent.providerError({ + message: providerErrorMessage(event), + classification: isContextOverflow(event.error?.message ?? "") ? "context-overflow" : undefined, + }), + ], ] const step = (state: ParserState, event: AnthropicEvent) => { diff --git a/packages/llm/src/protocols/bedrock-converse.ts b/packages/llm/src/protocols/bedrock-converse.ts index 2b3a2e95102..72143a48fbb 100644 --- a/packages/llm/src/protocols/bedrock-converse.ts +++ b/packages/llm/src/protocols/bedrock-converse.ts @@ -15,6 +15,7 @@ import { type ToolResultPart, } from "../schema" import { BedrockEventStream } from "./bedrock-event-stream" +import { isContextOverflow } from "../provider-error" import { JsonObject, optionalArray, ProviderShared } from "./shared" import { BedrockAuth } from "./utils/bedrock-auth" import { BedrockCache } from "./utils/bedrock-cache" @@ -582,7 +583,16 @@ const step = (state: ParserState, event: BedrockEvent) => if (event.validationException || event.throttlingException) { const message = event.validationException?.message ?? event.throttlingException?.message ?? "Bedrock Converse error" - return [state, [LLMEvent.providerError({ message, retryable: event.throttlingException !== undefined })]] as const + return [ + state, + [ + LLMEvent.providerError({ + message, + classification: event.validationException && isContextOverflow(message) ? "context-overflow" : undefined, + retryable: event.throttlingException !== undefined, + }), + ], + ] as const } return [state, []] as const diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 9bfdee44d8a..75cb95bc50b 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -18,6 +18,7 @@ import { type ToolResultPart, } from "../schema" import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared" +import { isContextOverflow } from "../provider-error" import { OpenAIOptions } from "./utils/openai-options" import { Lifecycle } from "./utils/lifecycle" import { ToolStream } from "./utils/tool-stream" @@ -880,14 +881,25 @@ const providerErrorMessage = (event: OpenAIResponsesEvent, fallback: string): st return message || code || fallback } +const providerError = (event: OpenAIResponsesEvent, fallback: string) => { + const code = event.code || event.response?.error?.code || undefined + return LLMEvent.providerError({ + message: providerErrorMessage(event, fallback), + classification: + code === "context_length_exceeded" || isContextOverflow(providerErrorMessage(event, fallback)) + ? "context-overflow" + : undefined, + }) +} + const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses response failed") })], + [providerError(event, "OpenAI Responses response failed")], ] const onError = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [ state, - [LLMEvent.providerError({ message: providerErrorMessage(event, "OpenAI Responses stream error") })], + [providerError(event, "OpenAI Responses stream error")], ] const step = (state: ParserState, event: OpenAIResponsesEvent) => { diff --git a/packages/llm/src/provider-error.ts b/packages/llm/src/provider-error.ts new file mode 100644 index 00000000000..924b8ffa678 --- /dev/null +++ b/packages/llm/src/provider-error.ts @@ -0,0 +1,24 @@ +const patterns = [ + /prompt is too long/i, + /input is too long for requested model/i, + /exceeds the context window/i, + /input token count.*exceeds the maximum/i, + /maximum prompt length is \d+/i, + /reduce the length of the messages/i, + /maximum context length is \d+ tokens/i, + /exceeds the limit of \d+/i, + /exceeds the available context size/i, + /greater than the context length/i, + /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, + /too large for model with \d+ maximum context length/i, + /model_context_window_exceeded/i, +] + +export const isContextOverflow = (message: string) => + patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) diff --git a/packages/llm/src/route/executor.ts b/packages/llm/src/route/executor.ts index 815b2c289c8..50dbec45756 100644 --- a/packages/llm/src/route/executor.ts +++ b/packages/llm/src/route/executor.ts @@ -22,6 +22,7 @@ import { TransportReason, UnknownProviderReason, } from "../schema" +import { isContextOverflow } from "../provider-error" export interface Interface { readonly execute: ( @@ -249,8 +250,21 @@ const statusReason = (input: { http: input.http, }) } - if (input.status === 400 || input.status === 404 || input.status === 409 || input.status === 422) { - return new InvalidRequestReason({ message: input.message, http: input.http }) + if ( + input.status === 400 || + input.status === 404 || + input.status === 409 || + input.status === 413 || + input.status === 422 + ) { + return new InvalidRequestReason({ + message: input.message, + classification: + /"code"\s*:\s*"context_length_exceeded"/i.test(body) || isContextOverflow(body) + ? "context-overflow" + : undefined, + http: input.http, + }) } if (input.status >= 500 || retryableStatus(input.status)) { return new ProviderInternalReason({ diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 39bf5b6252d..35546ca30b4 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -1,6 +1,9 @@ import { Schema } from "effect" import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids" +export const ProviderFailureClassification = Schema.Literal("context-overflow") +export type ProviderFailureClassification = typeof ProviderFailureClassification.Type + export class HttpRequestDetails extends Schema.Class("LLM.HttpRequestDetails")({ method: Schema.String, url: Schema.String, @@ -32,6 +35,7 @@ export class InvalidRequestReason extends Schema.Class("LL _tag: Schema.tag("InvalidRequest"), message: Schema.String, parameter: Schema.optional(Schema.String), + classification: Schema.optional(ProviderFailureClassification), providerMetadata: Schema.optional(ProviderMetadata), http: Schema.optional(HttpContext), }) { diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 67ba6a9eb3b..a685f07d5e1 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -2,6 +2,7 @@ import { Schema } from "effect" import { ContentBlockID, FinishReason, ProtocolID, ProviderMetadata, RouteID, ToolCallID } from "./ids" import { ModelSchema } from "./options" import { ToolOutput, ToolResultValue } from "./messages" +import { ProviderFailureClassification } from "./errors" /** * Token usage reported by an LLM provider. @@ -199,6 +200,7 @@ export type Finish = Schema.Schema.Type export const ProviderErrorEvent = Schema.Struct({ type: Schema.tag("provider-error"), message: Schema.String, + classification: Schema.optional(ProviderFailureClassification), retryable: Schema.optional(Schema.Boolean), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ProviderError" }) diff --git a/packages/llm/test/executor.test.ts b/packages/llm/test/executor.test.ts index fb8a4670787..811f7a9ffe7 100644 --- a/packages/llm/test/executor.test.ts +++ b/packages/llm/test/executor.test.ts @@ -73,6 +73,46 @@ const expectLLMError = (error: unknown) => { const errorHttp = (error: LLMError) => ("http" in error.reason ? error.reason.http : undefined) describe("RequestExecutor", () => { + it.effect("classifies context overflow responses", () => + 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", classification: "context-overflow" }) + }).pipe( + Effect.provide( + responsesLayer([ + new Response('{"error":{"code":"context_length_exceeded","message":"prompt too long"}}', { + status: 400, + }), + ]), + ), + ), + ) + + it.effect("does not classify generic HTTP 413 payload errors as context overflow", () => + 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() + }).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))), + ) + + it.effect("does not classify ordinary invalid requests as context overflow", () => + 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() + }).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))), + ) + it.effect("returns redacted diagnostics for retryable rate limits", () => Effect.gen(function* () { const executor = yield* RequestExecutor.Service diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index 1cd8f4dd9e1..f9f4650c5ae 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -477,6 +477,29 @@ describe("Anthropic Messages route", () => { }), ) + it.effect("classifies prompt-too-long provider errors", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + type: "error", + error: { type: "invalid_request_error", message: "prompt is too long: 210000 tokens" }, + }), + ), + ), + ) + + expect(response.events).toEqual([ + { + type: "provider-error", + message: "invalid_request_error: prompt is too long: 210000 tokens", + classification: "context-overflow", + }, + ]) + }), + ) + it.effect("falls back to error type when no message is present", () => Effect.gen(function* () { const response = yield* LLMClient.generate(request).pipe( diff --git a/packages/llm/test/provider/bedrock-converse.test.ts b/packages/llm/test/provider/bedrock-converse.test.ts index d6ba144c815..a536a1714a4 100644 --- a/packages/llm/test/provider/bedrock-converse.test.ts +++ b/packages/llm/test/provider/bedrock-converse.test.ts @@ -351,6 +351,23 @@ describe("Bedrock Converse route", () => { }), ) + it.effect("classifies input-too-long validation exceptions", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(baseRequest).pipe( + Effect.provide( + fixedBytes(eventStreamBody(["validationException", { message: "Input is too long for requested model" }])), + ), + ) + + expect(response.events.find((event) => event.type === "provider-error")).toEqual({ + type: "provider-error", + message: "Input is too long for requested model", + classification: "context-overflow", + retryable: false, + }) + }), + ) + it.effect("rejects requests with no auth path", () => Effect.gen(function* () { const unsignedModel = AmazonBedrock.configure({ diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index fa0855ff311..f9d739fbd8f 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -1351,7 +1351,13 @@ describe("OpenAI Responses route", () => { ), ) - expect(response.events).toEqual([{ type: "provider-error", message: "context_length_exceeded: prompt too long" }]) + expect(response.events).toEqual([ + { + type: "provider-error", + message: "context_length_exceeded: prompt too long", + classification: "context-overflow", + }, + ]) }), ) diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 0d5a5130c46..21149a2cf38 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -2,6 +2,7 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" import type { ProviderV2 } from "@opencode-ai/core/provider" +import { isContextOverflow } from "@opencode-ai/llm" export class HeaderTimeoutError extends Error { public override readonly name = "ProviderHeaderTimeoutError" @@ -19,30 +20,6 @@ export class ResponseStreamError extends Error { } } -// 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, DeepSeek, vLLM - /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, Moonshot - /context[_ ]length[_ ]exceeded/i, // Generic fallback - /request entity too large/i, // HTTP 413 - /context length is only \d+ tokens/i, // vLLM - /input length.*exceeds.*context length/i, // vLLM - /prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error - /too large for model with \d+ maximum context length/i, // Mistral - /model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text -] - function isOpenAiErrorRetryable(e: APICallError) { const status = e.statusCode if (!status) return e.isRetryable @@ -52,15 +29,6 @@ function isOpenAiErrorRetryable(e: APICallError) { // 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 message(providerID: ProviderV2.ID, e: APICallError) { return iife(() => { const msg = e.message @@ -197,7 +165,7 @@ export type ParsedAPICallError = export function parseAPICallError(input: { providerID: ProviderV2.ID; error: APICallError }): ParsedAPICallError { const m = message(input.providerID, input.error) const body = json(input.error.responseBody) - if (isOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { + if (isContextOverflow(m) || input.error.statusCode === 413 || body?.error?.code === "context_length_exceeded") { return { type: "context_overflow", message: m,