Skip to content

Commit fa8b091

Browse files
authored
Merge pull request #169 from browser-use/screenshot-file-refs
feat(provider): upload screenshots once before gateway serialization
2 parents b2f8e65 + e701221 commit fa8b091

4 files changed

Lines changed: 1110 additions & 1 deletion

File tree

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import type { LanguageModelV3, LanguageModelV3CallOptions } from "@ai-sdk/provider"
2+
3+
type Uploaded = { file_id: string; expires_at: number; signature: string }
4+
type Options = { baseURL: string; apiKey: string; fetch?: typeof fetch; now?: () => number }
5+
type RunCache = {
6+
owners: Set<symbol>
7+
cache: Map<string, Promise<Uploaded>>
8+
refreshed: Set<string>
9+
retired: Uploaded[]
10+
waiting: Array<() => void>
11+
active: number
12+
closed: boolean
13+
}
14+
const runCaches = new Map<string, RunCache>()
15+
16+
export function withImageFiles(model: LanguageModelV3, options: Options) {
17+
let state: RunCache = {
18+
owners: new Set(),
19+
cache: new Map(),
20+
refreshed: new Set(),
21+
retired: [],
22+
waiting: [],
23+
active: 0,
24+
closed: false,
25+
}
26+
const owner = Symbol()
27+
let closed = false
28+
const now = options.now ?? Date.now
29+
const endpoint = options.baseURL.replace(/\/$/, "") + "/image-files"
30+
const send = options.fetch ?? fetch
31+
let scope: string | undefined
32+
let routeHeaders: Record<string, string> = {}
33+
const anthropic = /\/anthropic\/v1\/?$/.test(new URL(options.baseURL).pathname)
34+
const enabled =
35+
Boolean(options.apiKey) &&
36+
/^\/(?:_v4\/bcode-relay\/[^/]+\/)?api\/v4\/llm\/(?:openai|anthropic)\/v1\/?$/.test(
37+
new URL(options.baseURL).pathname,
38+
)
39+
40+
async function prepare(params: LanguageModelV3CallOptions): Promise<LanguageModelV3CallOptions> {
41+
if (!enabled) return params
42+
const hasImages = params.prompt.some(
43+
(message) =>
44+
(message.role === "user" &&
45+
message.content.some(
46+
(part) =>
47+
part.type === "file" &&
48+
part.mediaType.startsWith("image/") &&
49+
(!(part.data instanceof URL) || part.data.protocol === "data:"),
50+
)) ||
51+
(message.role === "tool" &&
52+
message.content.some(
53+
(part) =>
54+
part.type === "tool-result" &&
55+
part.output.type === "content" &&
56+
part.output.value.some((item) => item.type === "image-data"),
57+
)),
58+
)
59+
if (!hasImages) return params
60+
if (closed || state.closed) throw new Error("Screenshot file cache is closed")
61+
const previous = params.providerOptions?.openai?.previousResponseId
62+
const legacy = params.prompt.some(
63+
(message) =>
64+
message.role === "assistant" &&
65+
message.content.some(
66+
(part) =>
67+
part.type === "reasoning" &&
68+
String(part.providerOptions?.openai?.reasoningEncryptedContent ?? "").startsWith("gAAAAA"),
69+
),
70+
)
71+
const currentHeaders = {
72+
Authorization: `Bearer ${options.apiKey}`,
73+
...(typeof previous === "string" ? { "x-bu-previous-response-id": previous } : {}),
74+
...(legacy ? { "x-bu-openai-reasoning": "1" } : {}),
75+
}
76+
const signal = AbortSignal.any([...(params.abortSignal ? [params.abortSignal] : []), AbortSignal.timeout(30_000)])
77+
const capability = await send(`${endpoint}/capability?model=${encodeURIComponent(model.modelId)}`, {
78+
headers: currentHeaders,
79+
signal,
80+
})
81+
// Older gateways have no upload contract. Never fall back after selecting reference mode.
82+
if (capability.status === 404 && !scope) return params
83+
if (!capability.ok) throw new Error(`Screenshot capability failed (${capability.status})`)
84+
const route = (await capability.json()) as { supported: boolean; scope?: string }
85+
if (!route.supported && !scope) return params
86+
if (!route.supported || !route.scope || (scope && scope !== route.scope))
87+
throw new Error("Screenshot route or account changed; start a new run")
88+
if (closed || state.closed) throw new Error("Screenshot file cache is closed")
89+
scope = route.scope
90+
if (!runCaches.has(scope) && runCaches.size >= 128) throw new Error("Too many active screenshot caches")
91+
state = runCaches.get(scope) ?? state
92+
state.owners.add(owner)
93+
runCaches.set(scope, state)
94+
routeHeaders = currentHeaders
95+
const headers = { ...currentHeaders, "x-bu-image-scope": scope }
96+
const proofs: Record<string, { expires_at: number; signature: string }> = {}
97+
async function upload(data: string | Uint8Array | URL, mediaType: string) {
98+
if (state.active >= 2) await new Promise<void>((resolve) => state.waiting.push(resolve))
99+
else state.active++
100+
try {
101+
signal.throwIfAborted()
102+
if (closed || state.closed) throw new Error("Screenshot file cache is closed")
103+
const raw = data instanceof URL ? data.toString().split(",", 2)[1] : data
104+
const bytes = typeof raw === "string" ? Buffer.from(raw, "base64") : raw
105+
if (bytes.byteLength > 5 * 1024 * 1024) throw new Error("Screenshot exceeds upload limit")
106+
const hash = new Bun.CryptoHasher("sha256").update(bytes).digest("hex")
107+
const key = `${scope}:${mediaType}:${hash}`
108+
let existing = state.cache.get(key)
109+
if (existing) {
110+
const file = await existing
111+
if (state.cache.get(key) !== existing) existing = state.cache.get(key)
112+
else if (file.expires_at <= now() / 1000 + 30) {
113+
if (state.refreshed.has(key)) throw new Error("Screenshot reference expired again; start a new run")
114+
state.refreshed.add(key)
115+
state.retired.push(file)
116+
existing = undefined
117+
}
118+
}
119+
if (!existing && !state.cache.has(key) && state.cache.size >= 64)
120+
throw new Error("Screenshot file limit reached; start a new run")
121+
const pending =
122+
existing ??
123+
(async () => {
124+
const response = await send(`${endpoint}?model=${encodeURIComponent(model.modelId)}`, {
125+
method: "POST",
126+
headers: { ...headers, "content-type": mediaType },
127+
body: new Uint8Array(bytes),
128+
signal,
129+
})
130+
if (!response.ok) throw new Error(`Screenshot upload failed (${response.status})`)
131+
const uploaded = (await response.json()) as Uploaded
132+
if (!uploaded.file_id?.startsWith(anthropic ? "file_" : "file-"))
133+
throw new Error("Invalid screenshot file ID")
134+
if (!(uploaded.expires_at > now() / 1000 + 30)) throw new Error("Screenshot reference expired")
135+
return uploaded
136+
})()
137+
state.cache.set(key, pending)
138+
const uploaded = await pending.catch((error) => {
139+
if (state.cache.get(key) === pending) state.cache.delete(key)
140+
throw error
141+
})
142+
if (uploaded.expires_at <= now() / 1000 + 30) throw new Error("Screenshot reference expired; start a new run")
143+
signal.throwIfAborted()
144+
if (closed || state.closed) throw new Error("Screenshot file cache is closed")
145+
proofs[uploaded.file_id] = { expires_at: uploaded.expires_at, signature: uploaded.signature }
146+
return uploaded.file_id
147+
} finally {
148+
const next = state.waiting.shift()
149+
if (next) next()
150+
else state.active--
151+
}
152+
}
153+
const prompt = await Promise.all(
154+
params.prompt.map(async (message) => {
155+
if (message.role === "tool")
156+
return {
157+
...message,
158+
content: await Promise.all(
159+
message.content.map(async (part) => {
160+
if (part.type !== "tool-result" || part.output.type !== "content") return part
161+
return {
162+
...part,
163+
output: {
164+
...part.output,
165+
value: await Promise.all(
166+
part.output.value.map(async (item) => {
167+
if (item.type !== "image-data") return item
168+
return {
169+
type: "image-url" as const,
170+
url: `bu-${anthropic ? "anthropic" : "openai"}-file:${await upload(item.data, item.mediaType)}`,
171+
providerOptions: item.providerOptions,
172+
}
173+
}),
174+
),
175+
},
176+
}
177+
}),
178+
),
179+
}
180+
if (message.role !== "user") return message
181+
return {
182+
...message,
183+
content: await Promise.all(
184+
message.content.map(async (part) => {
185+
if (
186+
part.type !== "file" ||
187+
!part.mediaType.startsWith("image/") ||
188+
(part.data instanceof URL && part.data.protocol !== "data:")
189+
)
190+
return part
191+
const id = await upload(part.data, part.mediaType)
192+
return { ...part, data: anthropic ? new URL(`bu-anthropic-file:${id}`) : id }
193+
}),
194+
),
195+
}
196+
}),
197+
)
198+
return {
199+
...params,
200+
prompt,
201+
headers: { ...params.headers, "x-bu-image-scope": scope, "x-bu-image-proofs": JSON.stringify(proofs) },
202+
}
203+
}
204+
205+
async function invoke<T>(
206+
params: LanguageModelV3CallOptions,
207+
call: (prepared: LanguageModelV3CallOptions) => PromiseLike<T>,
208+
): Promise<T> {
209+
try {
210+
return await call(await prepare(params))
211+
} catch (error) {
212+
const message = error instanceof Error ? error.message : ""
213+
if (!/file.*(not found|does not exist|expired|deleted)/i.test(message)) throw error
214+
const entries = await Promise.all([...state.cache].map(async ([key, pending]) => ({ key, file: await pending })))
215+
const missing = entries.filter(({ file }) => message.includes(file.file_id))
216+
if (!missing.length || missing.some(({ key }) => state.refreshed.has(key))) throw error
217+
for (const { key, file } of missing) {
218+
state.refreshed.add(key)
219+
state.retired.push(file)
220+
state.cache.delete(key)
221+
}
222+
return await call(await prepare(params))
223+
}
224+
}
225+
226+
return {
227+
specificationVersion: model.specificationVersion,
228+
provider: model.provider,
229+
modelId: model.modelId,
230+
supportedUrls: model.supportedUrls,
231+
async doGenerate(params: LanguageModelV3CallOptions) {
232+
return invoke(params, (prepared) => model.doGenerate(prepared))
233+
},
234+
async doStream(params: LanguageModelV3CallOptions) {
235+
return invoke(params, (prepared) => model.doStream(prepared))
236+
},
237+
async closeImageFiles() {
238+
if (closed) return
239+
closed = true
240+
state.owners.delete(owner)
241+
if (state.owners.size) return
242+
state.closed = true
243+
if (scope && runCaches.get(scope) === state) runCaches.delete(scope)
244+
const entries = await Promise.allSettled([
245+
...state.cache.values(),
246+
...state.retired.map((file) => Promise.resolve(file)),
247+
])
248+
await Promise.allSettled(
249+
entries
250+
.filter((entry) => entry.status === "fulfilled")
251+
.map((entry) =>
252+
send(`${endpoint}/${encodeURIComponent(entry.value.file_id)}?model=${encodeURIComponent(model.modelId)}`, {
253+
method: "DELETE",
254+
headers: { ...routeHeaders, "x-bu-image-scope": scope! },
255+
signal: AbortSignal.timeout(10_000),
256+
}),
257+
),
258+
)
259+
state.cache.clear()
260+
},
261+
}
262+
}
263+
264+
// The pinned SDK lacks tool-result file IDs; only small placeholders enter its JSON serializer.
265+
export function imageFileRequest(init?: RequestInit): RequestInit | undefined {
266+
if (!init || !new Headers(init.headers).has("x-bu-image-scope") || typeof init.body !== "string") return init
267+
const body = JSON.parse(init.body)
268+
if (Array.isArray(body.input))
269+
body.input = body.input.map((item: Record<string, unknown>) => {
270+
if (!item || typeof item !== "object") return item
271+
const field =
272+
item.type === "function_call_output" || item.type === "custom_tool_call_output" ? "output" : "content"
273+
if (!Array.isArray(item[field])) return item
274+
return {
275+
...item,
276+
[field]: item[field].map((part: Record<string, unknown>) => {
277+
if (
278+
part?.type !== "input_image" ||
279+
typeof part.image_url !== "string" ||
280+
!part.image_url.startsWith("bu-openai-file:file-")
281+
)
282+
return part
283+
const { image_url, ...rest } = part
284+
return { ...rest, file_id: image_url.slice("bu-openai-file:".length) }
285+
}),
286+
}
287+
})
288+
if (Array.isArray(body.messages)) {
289+
const rewrite = (content: Array<Record<string, unknown>>): Array<Record<string, unknown>> =>
290+
content.map((part) => {
291+
if (part?.type === "tool_result" && Array.isArray(part.content))
292+
return { ...part, content: rewrite(part.content) }
293+
const source = part?.source as { type?: string; url?: string } | undefined
294+
if (part?.type !== "image" || source?.type !== "url" || !source.url?.startsWith("bu-anthropic-file:file_"))
295+
return part
296+
return { ...part, source: { type: "file", file_id: source.url.slice("bu-anthropic-file:".length) } }
297+
})
298+
body.messages = body.messages.map((message: Record<string, unknown>) =>
299+
Array.isArray(message.content) ? { ...message, content: rewrite(message.content) } : message,
300+
)
301+
const headers = new Headers(init.headers)
302+
headers.set(
303+
"anthropic-beta",
304+
[...new Set([...(headers.get("anthropic-beta")?.split(",") ?? []), "files-api-2025-04-14"])].join(","),
305+
)
306+
return { ...init, headers, body: JSON.stringify(body) }
307+
}
308+
return { ...init, body: JSON.stringify(body) }
309+
}

‎packages/opencode/src/provider/provider.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { imageFileRequest, withImageFiles } from "./image-files"
12
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
23
import os from "os"
34
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
@@ -1158,6 +1159,7 @@ export interface Interface {
11581159
}
11591160

11601161
interface State {
1162+
imageFiles: Set<() => Promise<void>>
11611163
models: Map<string, LanguageModelV3>
11621164
providers: Record<ProviderV2.ID, Info>
11631165
catalog: Record<ProviderV2.ID, Info>
@@ -1344,6 +1346,12 @@ const layer = Layer.effect(
13441346

13451347
const providers: Record<ProviderV2.ID, Info> = {} as Record<ProviderV2.ID, Info>
13461348
const languages = new Map<string, LanguageModelV3>()
1349+
const imageFiles = new Set<() => Promise<void>>()
1350+
yield* Effect.addFinalizer(() =>
1351+
Effect.promise(async () => {
1352+
await Promise.allSettled([...imageFiles].map((close) => close()))
1353+
}),
1354+
)
13471355
const modelLoaders: {
13481356
[providerID: string]: CustomModelLoader
13491357
} = {}
@@ -1653,6 +1661,7 @@ const layer = Layer.effect(
16531661

16541662
return {
16551663
models: languages,
1664+
imageFiles,
16561665
providers,
16571666
catalog,
16581667
sdk,
@@ -1752,7 +1761,7 @@ const layer = Layer.effect(
17521761
if (combined) opts.signal = combined
17531762

17541763
const res = await fetchFn(input, {
1755-
...opts,
1764+
...imageFileRequest(opts),
17561765
// @ts-ignore see here: https://github.com/oven-sh/bun/issues/16682
17571766
timeout: false,
17581767
}).finally(() => headerTimeoutCtl?.clear())
@@ -1768,6 +1777,35 @@ const layer = Layer.effect(
17681777
name: model.providerID,
17691778
...options,
17701779
})
1780+
if (
1781+
model.api.npm === "@ai-sdk/openai" &&
1782+
loaded.responses &&
1783+
typeof options.apiKey === "string" &&
1784+
typeof baseURL === "string"
1785+
) {
1786+
const responses = loaded.responses.bind(loaded)
1787+
loaded.responses = (id: string) => {
1788+
const wrapped = withImageFiles(responses(id), { baseURL, apiKey: options.apiKey, fetch: options.fetch })
1789+
s.imageFiles.add(wrapped.closeImageFiles)
1790+
return wrapped
1791+
}
1792+
}
1793+
if (
1794+
model.api.npm === "@ai-sdk/anthropic" &&
1795+
typeof options.apiKey === "string" &&
1796+
typeof baseURL === "string"
1797+
) {
1798+
const languageModel = loaded.languageModel.bind(loaded)
1799+
loaded.languageModel = (id: string) => {
1800+
const wrapped = withImageFiles(languageModel(id), {
1801+
baseURL,
1802+
apiKey: options.apiKey,
1803+
fetch: options.fetch,
1804+
})
1805+
s.imageFiles.add(wrapped.closeImageFiles)
1806+
return wrapped
1807+
}
1808+
}
17711809
s.sdk.set(key, loaded)
17721810
return loaded as SDK
17731811
}

0 commit comments

Comments
 (0)