Skip to content

Commit 560b11b

Browse files
authored
fix(ai): accept url image results from OpenAI-style image APIs (#59474)
1 parent 8d26861 commit 560b11b

3 files changed

Lines changed: 54 additions & 4 deletions

File tree

‎packages/ai/src/protocols/openai-images.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import { Framing } from "../route/framing.js"
1313
import { MediaProtocol } from "../route/media-protocol.js"
1414
import { MediaRoute } from "../route/media.js"
1515
import { AIError, mergeJsonRecords, ProviderID, type MediaUsage, type OpenString } from "../schema/index.js"
16-
import { ProviderShared } from "./shared.js"
16+
import { optionalNull, ProviderShared } from "./shared.js"
1717
import { ImageSizing } from "./utils/image-sizing.js"
1818
import { MediaInput } from "./utils/media-input.js"
1919

@@ -64,7 +64,8 @@ const Settings = {
6464
}
6565

6666
const OpenAIImageResponse = Schema.Struct({
67-
data: Schema.Array(Schema.Struct({ b64_json: Schema.String })),
67+
// `response_format` picks `b64_json` or `url`; DALL·E and many compatible hosts default to `url`.
68+
data: Schema.Array(Schema.Struct({ b64_json: optionalNull(Schema.String), url: optionalNull(Schema.String) })),
6869
...Settings,
6970
usage: Schema.optional(Usage),
7071
})
@@ -307,7 +308,7 @@ const onDocument = Effect.fnUntraced(function* (frame: Exclude<Frame, string>) {
307308
const format = decoded.output_format
308309
const mediaType = format === undefined ? undefined : `image/${format}`
309310
const images = yield* Effect.forEach(decoded.data, (item, index) =>
310-
MediaInput.decodedAsset(invalid, `${route.name} result ${index}`, item.b64_json, mediaType, {
311+
MediaInput.imageOutput(invalid, `${route.name} result ${index}`, item, mediaType, {
311312
info: info(format, decoded.size),
312313
}),
313314
)

‎packages/ai/src/protocols/utils/media-input.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,10 @@ export const decodedAsset = (
5353
Effect.map((bytes) => Media.bytes(bytes, mediaType, options)),
5454
)
5555

56-
/** One image of an OpenAI-shaped `data` array, which carries either `b64_json` or a `url`. */
56+
/**
57+
* One image of an OpenAI-shaped `data` array, which carries `b64_json`, a `url`, or both. `b64_json` wins; a `data:`
58+
* URL decodes inline with its own media type, and any other URL stays a URL asset for the caller to download.
59+
*/
5760
export const imageOutput = (
5861
invalid: (message: string, cause?: unknown) => AIError,
5962
label: string,
@@ -62,6 +65,10 @@ export const imageOutput = (
6265
options?: Media.AssetOptions,
6366
) => {
6467
if (item.b64_json) return decodedAsset(invalid, label, item.b64_json, mediaType, options)
68+
if (item.url?.startsWith("data:")) {
69+
const asset = Media.parseDataUrl(item.url, options)
70+
return asset === undefined ? Effect.fail(invalid(`${label} has an invalid data URL`)) : Effect.succeed(asset)
71+
}
6572
if (item.url) return Effect.succeed(Media.url(item.url, { ...options, mediaType }))
6673
return Effect.fail(invalid(`${label} has neither image data nor a URL`))
6774
}

‎packages/ai/test/image.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,48 @@ describe("Image", () => {
169169
}),
170170
)
171171

172+
describe("OpenAI-style url results", () => {
173+
const generate = (data: unknown) =>
174+
Image.generate({
175+
model: OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).image("dall-e-3"),
176+
prompt: "A lighthouse",
177+
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { data })))))
178+
179+
it.effect("keeps an http url as a URL asset", () =>
180+
Effect.gen(function* () {
181+
const response = yield* generate([{ url: "https://cdn.image.test/result.png" }])
182+
expect(response.image.source).toEqual({ type: "url", url: "https://cdn.image.test/result.png" })
183+
}),
184+
)
185+
186+
it.effect("decodes a data url inline with its media type", () =>
187+
Effect.gen(function* () {
188+
const response = yield* generate([{ url: "data:image/png;base64,iVBORw==" }])
189+
expect(response.image.source).toEqual({ type: "base64", data: "iVBORw==", mediaType: "image/png" })
190+
expect(response.image.mediaType).toBe("image/png")
191+
}),
192+
)
193+
194+
it.effect("prefers b64_json when url is null", () =>
195+
Effect.gen(function* () {
196+
const response = yield* generate([{ b64_json: "/9j/4AAQ", url: null }])
197+
expect(response.image.source).toEqual({
198+
type: "bytes",
199+
data: Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]),
200+
mediaType: "image/jpeg",
201+
})
202+
}),
203+
)
204+
205+
it.effect("rejects an item with neither b64_json nor url", () =>
206+
Effect.gen(function* () {
207+
const error = yield* generate([{}]).pipe(Effect.flip)
208+
expect(error.reason._tag).toBe("InvalidProviderOutput")
209+
expect(error.message).toContain("has neither image data nor a URL")
210+
}),
211+
)
212+
})
213+
172214
it.effect("classifies an OpenAI stream moderation error event as a content policy rejection", () =>
173215
Effect.gen(function* () {
174216
const error = yield* Image.stream({

0 commit comments

Comments
 (0)