Skip to content

Commit b98b9b5

Browse files
Apply PR #26401: feat: better image handling (auto resize & max size constraints)
2 parents 8e32ad8 + 31e3fe8 commit b98b9b5

18 files changed

Lines changed: 414 additions & 34 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: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
},
134134
"patchedDependencies": {
135135
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
136+
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
136137
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
137138
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
138139
}

packages/opencode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@
119119
"@opentui/solid": "catalog:",
120120
"@parcel/watcher": "2.5.1",
121121
"@pierre/diffs": "catalog:",
122+
"@silvia-odwyer/photon-node": "0.3.4",
122123
"@solid-primitives/event-bus": "1.1.2",
123124
"@solid-primitives/scheduled": "1.5.2",
124125
"@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 "@opencode-ai/core/effect-zod"
5+
import { PositiveInt, withStatics } from "@opencode-ai/core/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 "@opencode-ai/core/effect-zod"
2626
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@opencode-ai/core/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: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
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+
6+
const MAX_BASE64_BYTES = 4.5 * 1024 * 1024
7+
const MAX_WIDTH = 2000
8+
const MAX_HEIGHT = 2000
9+
const AUTO_RESIZE = true
10+
const JPEG_QUALITIES = [80, 85, 70, 55, 40]
11+
const log = Log.create({ service: "image" })
12+
13+
type Photon = typeof import("@silvia-odwyer/photon-node")
14+
15+
export class PhotonUnavailableError extends Schema.TaggedErrorClass<PhotonUnavailableError>()(
16+
"ImagePhotonUnavailableError",
17+
{},
18+
) {
19+
override get message() {
20+
return "Photon image processor is unavailable"
21+
}
22+
}
23+
24+
export class InvalidDataUrlError extends Schema.TaggedErrorClass<InvalidDataUrlError>()("ImageInvalidDataUrlError", {
25+
url: Schema.String,
26+
}) {
27+
override get message() {
28+
return "Image URL must be a base64 data URL"
29+
}
30+
}
31+
32+
export class DecodeError extends Schema.TaggedErrorClass<DecodeError>()("ImageDecodeError", {}) {
33+
override get message() {
34+
return "Image could not be decoded"
35+
}
36+
}
37+
38+
export class SizeError extends Schema.TaggedErrorClass<SizeError>()("ImageSizeError", {
39+
bytes: Schema.Number,
40+
max: Schema.Number,
41+
width: Schema.Number,
42+
height: Schema.Number,
43+
max_width: Schema.Number,
44+
max_height: Schema.Number,
45+
}) {
46+
override get message() {
47+
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`
48+
}
49+
}
50+
51+
export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError
52+
53+
export interface Interface {
54+
readonly normalize: (input: MessageV2.FilePart) => Effect.Effect<MessageV2.FilePart, Error>
55+
}
56+
57+
export class Service extends Context.Service<Service, Interface>()("@opencode/Image") {}
58+
59+
export const layer = Layer.effect(
60+
Service,
61+
Effect.gen(function* () {
62+
const config = yield* Config.Service
63+
const loadPhoton = yield* Effect.cached(
64+
Effect.promise(async () => {
65+
const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default
66+
;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = photonWasm
67+
try {
68+
return await import("@silvia-odwyer/photon-node")
69+
} catch {
70+
return null
71+
}
72+
}),
73+
)
74+
75+
const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) {
76+
const image = (yield* config.get()).attachment?.image
77+
const info = {
78+
autoResize: image?.auto_resize ?? AUTO_RESIZE,
79+
maxWidth: image?.max_width ?? MAX_WIDTH,
80+
maxHeight: image?.max_height ?? MAX_HEIGHT,
81+
maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES,
82+
}
83+
if (!input.url.startsWith("data:") || !input.url.includes(";base64,"))
84+
return yield* new InvalidDataUrlError({ url: input.url })
85+
86+
const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length)
87+
const photon = yield* loadPhoton
88+
if (!photon) return yield* new PhotonUnavailableError()
89+
90+
const decoded = yield* Effect.sync(() => {
91+
try {
92+
return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64"))
93+
} catch {
94+
return undefined
95+
}
96+
})
97+
if (!decoded) return yield* new DecodeError()
98+
99+
try {
100+
const originalWidth = decoded.get_width()
101+
const originalHeight = decoded.get_height()
102+
if (
103+
originalWidth <= info.maxWidth &&
104+
originalHeight <= info.maxHeight &&
105+
Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes
106+
)
107+
return input
108+
if (!info.autoResize)
109+
return yield* new SizeError({
110+
bytes: Buffer.byteLength(base64, "utf8"),
111+
max: info.maxBase64Bytes,
112+
width: originalWidth,
113+
height: originalHeight,
114+
max_width: info.maxWidth,
115+
max_height: info.maxHeight,
116+
})
117+
118+
const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight)
119+
for (const size of Array.from({ length: 32 }).reduce<Array<{ width: number; height: number }>>((acc) => {
120+
const previous = acc.at(-1) ?? {
121+
width: Math.max(1, Math.round(originalWidth * scale)),
122+
height: Math.max(1, Math.round(originalHeight * scale)),
123+
}
124+
const next = acc.length === 0
125+
? previous
126+
: {
127+
width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)),
128+
height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)),
129+
}
130+
return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next]
131+
}, [])) {
132+
const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3)
133+
const candidate = [
134+
{ data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" },
135+
...JPEG_QUALITIES.map((quality) => ({
136+
data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"),
137+
mime: "image/jpeg",
138+
})),
139+
]
140+
.map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") }))
141+
.find((item) => item.bytes <= info.maxBase64Bytes)
142+
resized.free()
143+
144+
if (candidate) {
145+
log.info("using resized image", {
146+
from_mime: input.mime,
147+
to_mime: candidate.mime,
148+
from: `${originalWidth}x${originalHeight}`,
149+
to: `${size.width}x${size.height}`,
150+
})
151+
return {
152+
...input,
153+
mime: candidate.mime,
154+
url: `data:${candidate.mime};base64,${candidate.data}`,
155+
}
156+
}
157+
}
158+
159+
return yield* new SizeError({
160+
bytes: Buffer.byteLength(base64, "utf8"),
161+
max: info.maxBase64Bytes,
162+
width: originalWidth,
163+
height: originalHeight,
164+
max_width: info.maxWidth,
165+
max_height: info.maxHeight,
166+
})
167+
} finally {
168+
decoded.free()
169+
}
170+
})
171+
172+
return Service.of({ normalize })
173+
}),
174+
)
175+
176+
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
177+
178+
export * as Image from "./image"

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

Lines changed: 23 additions & 19 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

@@ -258,18 +260,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
258260
}) {
259261
const instance = yield* InstanceState.context
260262
const workspace = yield* InstanceState.workspaceID
263+
const message = yield* promptSvc
264+
.prompt({
265+
...ctx.payload,
266+
sessionID: ctx.params.sessionID,
267+
})
268+
.pipe(
269+
Effect.provideService(InstanceRef, instance),
270+
Effect.provideService(WorkspaceRef, workspace),
271+
Effect.mapError(() => new HttpApiError.BadRequest({})),
272+
)
261273
return HttpServerResponse.stream(
262-
Stream.fromEffect(
263-
promptSvc
264-
.prompt({
265-
...ctx.payload,
266-
sessionID: ctx.params.sessionID,
267-
})
268-
.pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)),
269-
).pipe(
270-
Stream.map((message) => JSON.stringify(message)),
271-
Stream.encodeText,
272-
),
274+
Stream.make(JSON.stringify(message)).pipe(Stream.encodeText),
273275
{ contentType: "application/json" },
274276
)
275277
})
@@ -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: {

0 commit comments

Comments
 (0)