Skip to content
94 changes: 79 additions & 15 deletions packages/opencode/src/provider/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ const OVERFLOW_PATTERNS = [
/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
Expand All @@ -117,10 +116,13 @@ function isOpenAiErrorRetryable(e: APICallError) {
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)
// Cerebras and Mistral often return "400 (no body)" for context overflow.
// HTTP 413 is deliberately classified as request-body size below.
return /^400\s*(status code)?\s*\(no body\)/i.test(message)
}

function isRequestTooLarge(message: string) {
return /request entity too large|payload too large|^413\s*(status code)?\s*\(no body\)/i.test(message)
}

// Billing/quota failures providers report under inconsistent status codes and
Expand Down Expand Up @@ -312,6 +314,12 @@ export type ParsedStreamError =
message: string
responseBody: string
}
| {
type: "request_too_large"
message: string
responseBody: string
code: "request_too_large" | "payload_too_large"
}
| {
type: "api_error"
message: string
Expand All @@ -321,9 +329,34 @@ export type ParsedStreamError =
code?: string
}

function parseBareStreamMessage(input: unknown): ParsedStreamError | undefined {
const message =
typeof input === "string"
? input
: isRecord(input) && typeof input.message === "string"
? input.message
: undefined
if (!message) return
if (isOverflow(message)) {
return {
type: "context_overflow",
message,
responseBody: message,
}
}
if (isRequestTooLarge(message)) {
return {
type: "request_too_large",
message,
responseBody: message,
code: "request_too_large",
}
}
}

export function parseStreamError(input: unknown): ParsedStreamError | undefined {
const raw = json(input)
if (!isRecord(raw)) return
if (!isRecord(raw)) return parseBareStreamMessage(input)

const inner = typeof raw.message === "string" ? json(raw.message) : undefined
const cause = isRecord(raw.cause) ? raw.cause : undefined
Expand All @@ -334,20 +367,33 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined

const responseBody = JSON.stringify(body)
const error = body.type === "error" && isRecord(body.error) ? body.error : isBareProviderError(body) ? body : undefined
if (!error) return
if (!error) return parseBareStreamMessage(input)

// Read code from the resolved error only (never dig into an untyped body —
// that is the over-match guard `if (!error) return` above protects). Fall back
// to error.type so providers that put the code under `type` still classify.
const code =
typeof error.code === "string" ? error.code : typeof error.type === "string" ? error.type : undefined
const providerMessage = typeof error.message === "string" ? error.message : undefined
const wrapperMessage = typeof raw.message === "string" && !isStreamErrorBody(inner) ? raw.message : undefined
const evidence = `${providerMessage ?? ""}\n${typeof raw.message === "string" ? raw.message : ""}`
if (code === "context_length_exceeded" || isOverflow(evidence)) {
return {
type: "context_overflow",
message: providerMessage ?? wrapperMessage ?? "Input exceeds context window of this model",
responseBody,
}
}
if (code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(evidence)) {
return {
type: "request_too_large",
message: providerMessage ?? wrapperMessage ?? "Provider request is too large.",
responseBody,
code: code === "payload_too_large" ? code : "request_too_large",
}
}

switch (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",
Expand Down Expand Up @@ -409,7 +455,6 @@ export function parseStreamError(input: unknown): ParsedStreamError | undefined
}
}

const providerMessage = typeof error.message === "string" ? error.message : undefined
const text = `${providerMessage ?? ""}\n${responseBody}`
// No statusCode on the stream path, so only the unconditional strong billing
// patterns can match here (weak patterns are status-gated).
Expand Down Expand Up @@ -453,6 +498,13 @@ export type ParsedAPICallError =
message: string
responseBody?: string
}
| {
type: "request_too_large"
message: string
statusCode?: number
responseBody?: string
code: string
}
| {
type: "api_error"
message: string
Expand All @@ -470,13 +522,25 @@ export function parseAPICallError(input: { providerID: ProviderID; error: APICal
const body = json(input.error.responseBody)
const code = extractProviderCode(body)
const modelUnavailable = isOpenCodeModelUnavailable(input.providerID, body)
if (isOverflow(m) || input.error.statusCode === 413 || code === "context_length_exceeded") {
const requestTooLarge =
code === "request_too_large" || code === "payload_too_large" || isRequestTooLarge(m)
const contextOverflow = code === "context_length_exceeded" || isOverflow(m)
if (contextOverflow) {
return {
type: "context_overflow",
message: m,
responseBody: input.error.responseBody,
}
}
if (input.error.statusCode === 413 || requestTooLarge) {
return {
type: "request_too_large",
message: m,
statusCode: input.error.statusCode,
responseBody: input.error.responseBody,
code: code ?? "request_too_large",
}
}

const metadata = input.error.url ? { url: input.error.url } : undefined
// Billing failures arrive under inconsistent statuses (DeepSeek 402 or a
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/session/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export const layer: Layer.Layer<
}) {
// Size the retained tail the way it actually re-enters the live prompt:
// prompt.ts serializes messages with no options, i.e. full media and
// un-truncated tool output. Only the head is summarized cheaply (stripMedia
// un-truncated tool output. Only the head is summarized cheaply (stripped media
// + truncation in processCompaction()); the tail is kept verbatim. Using
// those head options here undercounts the tail, so select()/splitTurn()
// keep recent turns that overflow the very next live prompt.
Expand Down Expand Up @@ -493,7 +493,7 @@ export const layer: Layer.Layer<
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
stripMedia: true,
mediaProjection: "stripped",
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
toolInputMaxChars: TOOL_INPUT_MAX_CHARS,
})
Expand Down
151 changes: 135 additions & 16 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,13 +870,109 @@ function providerMeta(metadata: Record<string, any> | undefined) {
return Object.keys(rest).length > 0 ? rest : undefined
}

export type MediaProjection = "normal" | "degraded" | "stripped"

export interface ModelMessageProjectionOptions {
mediaProjection?: MediaProjection
mediaMaxBytes?: number
mediaMaxCount?: number
toolOutputMaxChars?: number
toolInputMaxChars?: number
}

const MEDIA_MAX_BYTES = 12 * 1024 * 1024
const MEDIA_MAX_COUNT = 8
const MEDIA_DEGRADED_MAX_COUNT = 2

function shouldSerializeMessage(message: WithParts) {
if (message.info.role !== "assistant" || !message.info.error) return true
return (
AbortedError.isInstance(message.info.error) &&
message.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
}

function mediaCandidates(input: WithParts[]) {
const candidates: FilePart[] = []
for (const message of input) {
if (!shouldSerializeMessage(message)) continue
for (const part of message.parts) {
if (part.type === "file" && isMedia(part.mime)) candidates.push(part)
if (part.type !== "tool" || part.state.status !== "completed" || part.state.time.compacted) continue
for (const attachment of part.state.attachments ?? []) {
if (isMedia(attachment.mime)) candidates.push(attachment)
}
}
}
return candidates
}

function mediaRequestBytes(part: FilePart) {
if (!part.url.startsWith("data:")) return 0
const comma = part.url.indexOf(",")
if (comma === -1) return new TextEncoder().encode(part.url).byteLength
return new TextEncoder().encode(part.url.slice(comma + 1)).byteLength
}

function selectedMedia(input: WithParts[], options?: ModelMessageProjectionOptions) {
const projection = options?.mediaProjection ?? "normal"
const configuredMax = options?.mediaMaxCount ?? MEDIA_MAX_COUNT
const maxCount =
projection === "stripped"
? 0
: projection === "degraded"
? Math.min(configuredMax, MEDIA_DEGRADED_MAX_COUNT)
: configuredMax
const maxBytes = projection === "stripped" ? 0 : (options?.mediaMaxBytes ?? MEDIA_MAX_BYTES)
const selected = new Set<FilePart>()
let usedBytes = 0
for (const part of mediaCandidates(input).reverse()) {
const bytes = mediaRequestBytes(part)
if (selected.size >= Math.max(0, maxCount) || usedBytes + bytes > Math.max(0, maxBytes)) continue
selected.add(part)
usedBytes += bytes
}
return selected
}

function mediaProjectionsAfter(current: MediaProjection) {
const projections: Exclude<MediaProjection, "normal">[] =
current === "normal" ? ["degraded", "stripped"] : current === "degraded" ? ["stripped"] : []
return projections
}

function modelMessagesBytes(messages: ModelMessage[]) {
return new TextEncoder().encode(JSON.stringify(messages)).byteLength
}

export async function nextMediaMessages(
input: WithParts[],
model: Provider.Model,
current: MediaProjection,
currentMessages: ModelMessage[],
suffix: ModelMessage[] = [],
) {
const currentBytes = modelMessagesBytes(currentMessages)
const projections = mediaProjectionsAfter(current)
for (const projection of projections) {
const messages = [...(await toModelMessages(input, model, { mediaProjection: projection })), ...suffix]
if (modelMessagesBytes(messages) >= currentBytes) continue
return { projection, messages }
}
}

function omittedMedia(part: Pick<FilePart, "mime" | "filename">) {
return `[Attached ${part.mime}: ${part.filename ?? "file"} omitted to fit the provider request limit]`
}

export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; toolInputMaxChars?: number },
options?: ModelMessageProjectionOptions,
) {
const result: UIMessage[] = []
const toolNames = new Set<string>()
const includedMedia = selectedMedia(input, options)
// Track media from tool results that need to be injected as user messages
// for providers that don't support that media type in tool results.
//
Expand Down Expand Up @@ -933,7 +1029,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
}

for (const msg of input) {
if (msg.parts.length === 0) continue
if (msg.parts.length === 0 || !shouldSerializeMessage(msg)) continue

if (msg.info.role === "user") {
const userMessage: UIMessage = {
Expand All @@ -949,10 +1045,10 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
})
// text/plain and directory files are converted into text parts, ignore them
if (part.type === "file" && part.mime !== "text/plain" && part.mime !== "application/x-directory") {
if (options?.stripMedia && isMedia(part.mime)) {
if (isMedia(part.mime) && !includedMedia.has(part)) {
userMessage.parts.push({
type: "text",
text: `[Attached ${part.mime}: ${part.filename ?? "file"}]`,
text: omittedMedia(part),
})
} else {
userMessage.parts.push({
Expand Down Expand Up @@ -984,15 +1080,6 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const media: Array<{ mime: string; url: string; filename?: string }> = []

if (
msg.info.error &&
!(
AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
Expand All @@ -1018,10 +1105,17 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted
const baseOutputText = part.state.time.compacted
? "[Old tool result content cleared]"
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
const sourceAttachments = part.state.time.compacted ? [] : (part.state.attachments ?? [])
const omittedAttachments = sourceAttachments.filter(
(attachment) => isMedia(attachment.mime) && !includedMedia.has(attachment),
)
const outputText = [baseOutputText, ...omittedAttachments.map(omittedMedia)].filter(Boolean).join("\n")
const attachments = sourceAttachments.filter(
(attachment) => !isMedia(attachment.mime) || includedMedia.has(attachment),
)

// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
Expand Down Expand Up @@ -1154,7 +1248,7 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
export function toModelMessages(
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean; toolOutputMaxChars?: number; toolInputMaxChars?: number },
options?: ModelMessageProjectionOptions,
): Promise<ModelMessage[]> {
return Effect.runPromise(toModelMessagesEffect(input, model, options).pipe(Effect.provide(EffectLogger.layer)))
}
Expand Down Expand Up @@ -1421,6 +1515,19 @@ export function fromError(
{ cause: e },
).toObject()
}
if (parsed.type === "request_too_large") {
return new APIError(
{
message: parsed.message,
statusCode: parsed.statusCode,
isRetryable: false,
responseBody: parsed.responseBody,
providerID: ctx.providerID,
providerFailure: { kind: "invalid_request", code: parsed.code },
},
{ cause: e },
).toObject()
}

return new APIError(
{
Expand Down Expand Up @@ -1452,6 +1559,18 @@ export function fromError(
{ cause: e },
).toObject()
}
if (parsed.type === "request_too_large") {
return new APIError(
{
message: parsed.message,
isRetryable: false,
responseBody: parsed.responseBody,
providerID: ctx.providerID,
providerFailure: { kind: "invalid_request", code: parsed.code },
},
{ cause: e },
).toObject()
}
return new APIError(
{
message: parsed.message,
Expand Down
Loading
Loading