Skip to content

Commit 874f802

Browse files
Apply PR #26401: feat: better image handling (auto resize & max size constraints)
2 parents 3b116a6 + 8e8fef4 commit 874f802

11 files changed

Lines changed: 358 additions & 19 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### General Principles
1010

1111
- Keep things in one function unless composable or reusable
12+
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
1213
- Avoid `try`/`catch` where possible
1314
- Avoid using the `any` type
1415
- Use Bun APIs when possible, like `Bun.file()`

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@
129129
"@opentui/solid": "catalog:",
130130
"@parcel/watcher": "2.5.1",
131131
"@pierre/diffs": "catalog:",
132+
"@silvia-odwyer/photon-node": "0.3.4",
132133
"@solid-primitives/event-bus": "1.1.2",
133134
"@solid-primitives/scheduled": "1.5.2",
134135
"@standard-schema/spec": "1.0.0",

packages/opencode/src/audio.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,8 @@ declare module "*.wav" {
22
const file: string
33
export default file
44
}
5+
6+
declare module "*.wasm" {
7+
const file: string
8+
export default file
9+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
export * as ConfigAttachment from "./attachment"
2+
3+
import { Schema } from "effect"
4+
import { zod } from "@/util/effect-zod"
5+
import { PositiveInt, withStatics } from "@/util/schema"
6+
7+
export const Image = Schema.Struct({
8+
auto_resize: Schema.optional(Schema.Boolean).annotate({
9+
description: "Resize images before sending them to the model when they exceed configured limits (default: true)",
10+
}),
11+
max_width: Schema.optional(PositiveInt).annotate({
12+
description: "Maximum image width before resizing or rejecting the attachment (default: 2000)",
13+
}),
14+
max_height: Schema.optional(PositiveInt).annotate({
15+
description: "Maximum image height before resizing or rejecting the attachment (default: 2000)",
16+
}),
17+
max_base64_bytes: Schema.optional(PositiveInt).annotate({
18+
description: "Maximum base64 payload bytes for an image attachment (default: 4718592)",
19+
}),
20+
})
21+
.annotate({ identifier: "ImageAttachmentConfig" })
22+
.pipe(withStatics((s) => ({ zod: zod(s) })))
23+
export type Image = Schema.Schema.Type<typeof Image>
24+
25+
export const Info = Schema.Struct({
26+
image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }),
27+
})
28+
.annotate({ identifier: "AttachmentConfig" })
29+
.pipe(withStatics((s) => ({ zod: zod(s) })))
30+
export type Info = Schema.Schema.Type<typeof Info>

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context"
2525
import { zod } from "@/util/effect-zod"
2626
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
2727
import { ConfigAgent } from "./agent"
28+
import { ConfigAttachment } from "./attachment"
2829
import { ConfigCommand } from "./command"
2930
import { ConfigFormatter } from "./formatter"
3031
import { ConfigLayout } from "./layout"
@@ -241,6 +242,9 @@ export const Info = Schema.Struct({
241242
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
242243
permission: Schema.optional(ConfigPermission.Info),
243244
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
245+
attachment: Schema.optional(ConfigAttachment.Info).annotate({
246+
description: "Attachment processing configuration, including image size limits and resizing behavior",
247+
}),
244248
enterprise: Schema.optional(
245249
Schema.Struct({
246250
url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }),
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { Config } from "@/config/config"
2+
import type { MessageV2 } from "@/session/message-v2"
3+
import * as Log from "@opencode-ai/core/util/log"
4+
import { Context, Effect, Layer, Schema } from "effect"
5+
import fs from "fs"
6+
7+
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
8+
const MAX_WIDTH = 2000
9+
const MAX_HEIGHT = 2000
10+
const AUTO_RESIZE = true
11+
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
12+
const log = Log.create({ service: "image" })
13+
14+
type Photon = typeof import("@silvia-odwyer/photon-node")
15+
16+
let photonModule: Photon | null = null
17+
let photonPromise: Promise<Photon | null> | null = null
18+
19+
function loadPhoton() {
20+
if (photonModule) return Promise.resolve(photonModule)
21+
if (photonPromise) return photonPromise
22+
23+
photonPromise = (async () => {
24+
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default
25+
const original = fs.readFileSync
26+
fs.readFileSync = ((file: fs.PathOrFileDescriptor, options?: Parameters<typeof fs.readFileSync>[1]) => {
27+
if (typeof file === "string" && file.endsWith("photon_rs_bg.wasm")) return original(photonWasm, options)
28+
return original(file, options)
29+
}) as typeof fs.readFileSync
30+
try {
31+
photonModule = await import("@silvia-odwyer/photon-node")
32+
return photonModule
33+
} catch {
34+
photonModule = null
35+
return null
36+
} finally {
37+
fs.readFileSync = original
38+
}
39+
})()
40+
41+
return photonPromise
42+
}
43+
44+
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
45+
"ImagePhotonUnavailableError",
46+
{},
47+
) {
48+
override get message() {
49+
return "Photon image processor is unavailable"
50+
}
51+
}
52+
53+
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
54+
url: Schema.String,
55+
}) {
56+
override get message() {
57+
return "Image URL must be a base64 data URL"
58+
}
59+
}
60+
61+
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
62+
override get message() {
63+
return "Image could not be decoded"
64+
}
65+
}
66+
67+
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
68+
bytes: Schema.Number,
69+
max: Schema.Number,
70+
width: Schema.Number,
71+
height: Schema.Number,
72+
max_width: Schema.Number,
73+
max_height: Schema.Number,
74+
}) {
75+
override get message() {
76+
return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes`
77+
}
78+
}
79+
80+
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
81+
82+
export interface Interface {
83+
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
84+
}
85+
86+
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
87+
88+
export const layer = Layer.effect(
89+
Service,
90+
Effect.gen(function* () {
91+
const config = yield* Config.Service
92+
93+
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
94+
const image = (yield* config.get()).attachment?.image
95+
const info = {
96+
autoResize: image?.auto_resize ?? AUTO_RESIZE,
97+
maxWidth: image?.max_width ?? MAX_WIDTH,
98+
maxHeight: image?.max_height ?? MAX_HEIGHT,
99+
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
100+
}
101+
if (!info.autoResize) return input
102+
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
103+
return yield* new InvalidDataUrlError({ url: input.url })
104+
105+
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
106+
const photon = yield* Effect.promise(loadPhoton)
107+
if (!photon) return yield* new PhotonUnavailableError()
108+
109+
const decoded = yield* Effect.sync(() => {
110+
try {
111+
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
112+
} catch {
113+
return undefined
114+
}
115+
})
116+
if (!decoded) return yield* new DecodeError()
117+
118+
try {
119+
const originalWidth = decoded.get_width()
120+
const originalHeight = decoded.get_height()
121+
if (
122+
originalWidth <= info.maxWidth &&
123+
originalHeight <= info.maxHeight &&
124+
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
125+
)
126+
return input
127+
128+
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
129+
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
130+
const previous = acc.at(-1) ?? {
131+
width: Math.max(1, Math.round(originalWidth * scale)),
132+
height: Math.max(1, Math.round(originalHeight * scale)),
133+
}
134+
const next = acc.length === 0
135+
? previous
136+
: {
137+
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
138+
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
139+
}
140+
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
141+
}, [])) {
142+
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
143+
const candidate = [
144+
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
145+
...JPEG_QUALITIES.map((quality) => ({
146+
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
147+
mime: "image/jpeg",
148+
})),
149+
]
150+
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
151+
.filter((item) => item.bytes <= info.maxBase64Bytes)
152+
.sort((a, b) => a.bytes - b.bytes)[0]
153+
resized.free()
154+
155+
if (candidate) {
156+
log.info("using resized image", {
157+
from_mime: input.mime,
158+
to_mime: candidate.mime,
159+
from: `${originalWidth}x${originalHeight}`,
160+
to: `${size.width}x${size.height}`,
161+
})
162+
return {
163+
...input,
164+
mime: candidate.mime,
165+
url: `data:${candidate.mime};base64,${candidate.data}`,
166+
}
167+
}
168+
}
169+
170+
return yield* new SizeError({
171+
bytes: Buffer.byteLength(base64, "utf8"),
172+
max: info.maxBase64Bytes,
173+
width: originalWidth,
174+
height: originalHeight,
175+
max_width: info.maxWidth,
176+
max_height: info.maxHeight,
177+
})
178+
} finally {
179+
decoded.free()
180+
}
181+
})
182+
183+
return Service.of({ normalize })
184+
}),
185+
)
186+
187+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
188+
189+
export * as Image from "./image"

packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,13 +203,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
203203
params: { sessionID: SessionID }
204204
payload: typeof InitPayload.Type
205205
}) {
206-
yield* promptSvc.command({
207-
sessionID: ctx.params.sessionID,
208-
messageID: ctx.payload.messageID,
209-
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
210-
command: Command.Default.INIT,
211-
arguments: "",
212-
})
206+
yield* promptSvc
207+
.command({
208+
sessionID: ctx.params.sessionID,
209+
messageID: ctx.payload.messageID,
210+
model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
211+
command: Command.Default.INIT,
212+
arguments: "",
213+
})
214+
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
213215
return true
214216
})
215217

@@ -297,7 +299,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
297299
params: { sessionID: SessionID }
298300
payload: typeof CommandPayload.Type
299301
}) {
300-
return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
302+
return yield* promptSvc
303+
.command({ ...ctx.payload, sessionID: ctx.params.sessionID })
304+
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
301305
})
302306

303307
const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {

packages/opencode/src/session/processor.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect"
1+
import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect"
22
import * as Stream from "effect/Stream"
33
import { Agent } from "@/agent/agent"
44
import { Bus } from "@/bus"
@@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot"
99
import * as Session from "./session"
1010
import { LLM } from "./llm"
1111
import { MessageV2 } from "./message-v2"
12+
import { Image } from "@/image/image"
1213
import { isOverflow } from "./overflow"
1314
import { PartID } from "./schema"
1415
import type { SessionID } from "./schema"
@@ -108,6 +109,7 @@ export const layer: Layer.Layer<
108109
const summary = yield* SessionSummary.Service
109110
const scope = yield* Scope.Scope
110111
const status = yield* SessionStatus.Service
112+
const image = yield* Image.Service
111113

112114
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
113115
// Pre-capture snapshot before the LLM stream starts. The AI SDK
@@ -183,16 +185,30 @@ export const layer: Layer.Layer<
183185
) {
184186
const match = yield* readToolCall(toolCallID)
185187
if (!match || match.part.state.status !== "running") return
188+
const normalized = output.attachments
189+
? yield* Effect.forEach(output.attachments, (attachment) =>
190+
attachment.mime.startsWith("image/")
191+
? image.normalize(attachment).pipe(Effect.exit)
192+
: Effect.succeed(Exit.succeed(attachment)),
193+
)
194+
: undefined
195+
const omitted = normalized?.filter(Exit.isFailure).length ?? 0
196+
const attachments = normalized
197+
?.filter(Exit.isSuccess)
198+
.map((item) => item.value)
186199
yield* session.updatePart({
187200
...match.part,
188201
state: {
189202
status: "completed",
190203
input: match.part.state.input,
191-
output: output.output,
204+
output:
205+
omitted === 0
206+
? output.output
207+
: `${output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`,
192208
metadata: output.metadata,
193209
title: output.title,
194210
time: { start: match.part.state.time.start, end: Date.now() },
195-
attachments: output.attachments,
211+
attachments: attachments?.length ? attachments : undefined,
196212
},
197213
})
198214
yield* settleToolCall(toolCallID)
@@ -746,7 +762,7 @@ export const layer: Layer.Layer<
746762

747763
return Service.of({ create })
748764
}),
749-
)
765+
).pipe(Layer.provide(Image.layer))
750766

751767
export const defaultLayer = Layer.suspend(() =>
752768
layer.pipe(

0 commit comments

Comments
 (0)