Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/llm/src/provider-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const patterns = [
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/request body too large/i,
/multimodal data size is too large/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
Expand Down
2 changes: 2 additions & 0 deletions packages/llm/test/provider-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe("provider error classification", () => {
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
"Too many tokens",
"Token limit exceeded",
"Request body too large",
"multimodal data size is too large",
]

expect(messages.every(isContextOverflow)).toBe(true)
Expand Down
76 changes: 69 additions & 7 deletions packages/opencode/src/session/message-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ interface FetchDecompressionError extends Error {
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached media from tool result:"
export { isMedia }

const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"

function truncateToolOutput(text: string, maxChars?: number) {
if (!maxChars || text.length <= maxChars) return text
const omitted = text.length - maxChars
Expand Down Expand Up @@ -416,13 +421,15 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (

const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }]))

return yield* Effect.promise(() =>
convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{
//@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput)
tools,
},
return boundImages(
yield* Effect.promise(() =>
convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{
//@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput)
tools,
},
),
),
)
})
Expand All @@ -435,6 +442,61 @@ export function toModelMessages(
return Effect.runPromise(toModelMessagesEffect(input, model, options))
}

/**
* Images stay in history and are re-sent on every request, but cost almost no tokens, so compaction never removes
* them. Once the inline images pass the trigger, the oldest are replaced with a placeholder until the rest fit the
* target.
*/
export function boundImages(messages: ModelMessage[]) {
const sizes: number[] = []
replaceImages(messages, (bytes) => {
sizes.push(bytes)
return false
})
// Replay the images in order, dropping the oldest down to the target whenever the kept payload passes the trigger.
// A new image then moves the cut only every (trigger - target) bytes instead of on every request, so the replaced
// prefix, and with it the provider's prompt cache, stays the same between cuts.
let cut = 0
let kept = 0
for (const size of sizes) {
kept += size
if (kept <= IMAGE_BYTES_TRIGGER) continue
while (kept > IMAGE_BYTES_TARGET) kept -= sizes[cut++]!
}
if (cut === 0) return messages

let index = 0
return replaceImages(messages, () => index++ < cut)
}

/** Visits inline images in send order and replaces the ones `remove` selects; unchanged messages keep their identity. */
function replaceImages(messages: ModelMessage[], remove: (bytes: number) => boolean) {
const isImage = (mime: string | undefined) => mime?.toLowerCase().startsWith("image/") ?? false
const placeholder = () => ({ type: "text" as const, text: IMAGE_REMOVED })
return messages.map((message) => {
if (typeof message.content === "string") return message
const parts: Exclude<ModelMessage["content"], string>[number][] = message.content
const content = parts.map((part) => {
if (part.type === "image") return remove(inlineBytes(part.image)) ? placeholder() : part
if (part.type === "file") return isImage(part.mediaType) && remove(inlineBytes(part.data)) ? placeholder() : part
if (part.type !== "tool-result" || part.output.type !== "content") return part
const output = part.output
const value = output.value.map((item) =>
"data" in item && isImage(item.mediaType) && remove(item.data.length) ? placeholder() : item,
)
return value.every((item, i) => item === output.value[i]) ? part : { ...part, output: { ...output, value } }
})
return content.every((part, i) => part === parts[i]) ? message : ({ ...message, content } as ModelMessage)
})
}

// Base64 and data URLs are ASCII, so a string's length is its size. Remote URLs carry no inline payload.
function inlineBytes(data: string | URL | Uint8Array | ArrayBuffer) {
if (data instanceof URL) return 0
if (typeof data === "string") return /^https?:\/\//i.test(data) ? 0 : data.length
return Math.ceil(data.byteLength / 3) * 4
}

export const page = Effect.fn("MessageV2.page")(function* (input: {
sessionID: SessionID
limit: number
Expand Down
130 changes: 129 additions & 1 deletion packages/opencode/test/session/message-v2.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { createXai } from "@ai-sdk/xai"
import { APICallError, generateText } from "ai"
import { APICallError, generateText, type ModelMessage } from "ai"
import { MessageV2 } from "../../src/session/message-v2"
import { ProviderTransform } from "@/provider/transform"
import type { Provider } from "@/provider/provider"
Expand Down Expand Up @@ -1553,6 +1553,132 @@ describe("session.message-v2.toModelMessage", () => {
})
})

describe("session.message-v2.boundImages", () => {
const image = (mib: number, filename: string): ModelMessage => ({
role: "user",
content: [{ type: "file", mediaType: "image/png", filename, data: "a".repeat(mib * 1024 * 1024) }],
})
const removed = (messages: ModelMessage[]) =>
messages.filter((message) => (message.content as Array<{ type: string }>)[0]?.type === "text").length

test("preserves images below the trigger", () => {
const messages = [image(1, "small.png")]
expect(MessageV2.boundImages(messages)).toBe(messages)
})

test("replaces oldest images until the retained payload reaches the target", () => {
const text: ModelMessage = { role: "user", content: [{ type: "text", text: "plain" }] }
const third = image(9, "third.png")
const messages = [image(9, "first.png"), text, image(9, "second.png"), third]
const result = MessageV2.boundImages(messages)

expect(result[0]?.content).toEqual([{ type: "text", text: expect.stringContaining("image was removed") }])
expect(result[1]).toBe(text)
expect(result[2]?.content).toEqual([{ type: "text", text: expect.stringContaining("image was removed") }])
expect(result[3]).toBe(third)
})

test("keeps the replaced prefix stable as new images arrive, so the prompt cache survives", () => {
const history = Array.from({ length: 9 }, (_, i) => image(3, `${i}.png`))

// 27 MiB passes the 25 MiB trigger: the oldest four go, leaving 15 MiB.
expect(removed(MessageV2.boundImages(history))).toBe(4)
// The cut holds until the kept payload passes the trigger again.
expect(removed(MessageV2.boundImages([...history, image(3, "9.png")]))).toBe(4)
expect(removed(MessageV2.boundImages([...history, ...[9, 10, 11].map((i) => image(3, `${i}.png`))]))).toBe(4)
expect(removed(MessageV2.boundImages([...history, ...[9, 10, 11, 12].map((i) => image(3, `${i}.png`))]))).toBe(8)
})

test("replaces images nested in tool results", () => {
const data = "a".repeat(13 * 1024 * 1024)
const result = MessageV2.boundImages([
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-1",
toolName: "read",
output: {
type: "content",
value: [
{ type: "media", mediaType: "image/png", data },
{ type: "media", mediaType: "image/png", data },
],
},
},
],
},
])

expect(result[0]?.content).toMatchObject([
{
type: "tool-result",
output: { type: "content", value: [{ type: "text" }, { type: "media", mediaType: "image/png" }] },
},
])
})

test("bounds tool-result images re-sent as synthetic user messages for OpenAI-compatible providers", async () => {
const compatibleModel: Provider.Model = {
...model,
api: { ...model.api, npm: "@ai-sdk/openai-compatible" },
capabilities: { ...model.capabilities, attachment: true, input: { ...model.capabilities.input, image: true } },
}
const userID = "m-user-bound"
const input: SessionV1.WithParts[] = [
{
info: userInfo(userID),
parts: [{ ...basePart(userID, "u1-bound"), type: "text", text: "read the screenshots" }] as SessionV1.Part[],
},
...[1, 2].map((turn) => {
const assistantID = `m-assistant-bound-${turn}`
return {
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, `a-bound-${turn}`),
type: "tool",
callID: `call-bound-${turn}`,
tool: "read",
state: {
status: "completed",
input: { filePath: `/tmp/screenshot-${turn}.png` },
output: "Image read successfully",
title: "Read",
metadata: {},
time: { start: 0, end: 1 },
attachments: [
{
...basePart(assistantID, `file-bound-${turn}`),
type: "file",
mime: "image/png",
filename: `screenshot-${turn}.png`,
url: `data:image/png;base64,${"a".repeat(13 * 1024 * 1024)}`,
},
],
},
},
] as SessionV1.Part[],
}
}),
]

const synthetic = (await MessageV2.toModelMessages(input, compatibleModel)).filter(
(message) =>
message.role === "user" &&
Array.isArray(message.content) &&
message.content[0]?.type === "text" &&
message.content[0].text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
)

expect(synthetic.map((message) => message.content.slice(1))).toMatchObject([
[{ type: "text", text: expect.stringContaining("image was removed") }],
[{ type: "file", filename: "screenshot-2.png" }],
])
})
})

describe("session.message-v2.fromError", () => {
test("serializes context_length_exceeded as ContextOverflowError", () => {
const input = {
Expand Down Expand Up @@ -1642,6 +1768,8 @@ describe("session.message-v2.fromError", () => {
"Please reduce the length of the messages or completion",
"400 status code (no body)",
"413 status code (no body)",
"Request body too large",
"multimodal data size is too large",
]

cases.forEach((message) => {
Expand Down
Loading