|
| 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 | +} |
0 commit comments