Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@ table before swapping only the `model`:
| Alibaba | ✓ | width × height | width × height | ✓ | `size`, `seed` |
| MiniMax | ✓ | preset or w × h⁴ | preset or w × h⁴ | 1 (portrait subject) | `aspect_ratio`, `width`, `height`, `seed` |
| Vercel AI Gateway | ✓ | as OpenAI³ | as OpenAI³ | per model | as OpenAI |
| OpenRouter | ✓ | ✓ (`aspect_ratio`) | tier⁵ | per model | as OpenAI |
| OpenRouter | ✓ | ✓ (`aspect_ratio`) | tier⁵ | per model⁵ | `size`, `outputFormat` (no `mask`) |
| Cloudflare Workers AI | 1 | width × height² | width × height² | ✗ | `width`, `height`, `seed` |

✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. "width × height"
Expand All @@ -721,7 +721,8 @@ forwards `outputFormat` but does not apply it, so the returned bytes keep the up
Recraft, reject sizes outside it. Grok (`spacexai/*`, `xai/*`) ignores `size`, so it gets `providerOptions.xai`
`aspect_ratio` and `1k`/`2k` instead. ⁴ MiniMax sends its `aspect_ratio` preset for a preset aspect at about `1K` and an
exact width × height (512–2048 px, steps of 8) otherwise; `image-01-live` takes only the presets, minus `21:9`.
⁵ OpenRouter takes `512`, `768`, `1K`, `1.5K`, `2K` or `4K` rather than pixels; other values are left out.
⁵ OpenRouter takes `512`, `768`, `1K`, `1.5K`, `2K` or `4K` rather than pixels; other values are left out. It has no
edits endpoint, so input images go to `/images/generations` as `input_references` (data URLs or http(s) URLs).

`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
Expand Down
55 changes: 46 additions & 9 deletions packages/ai/src/protocols/openai-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,22 @@ const reference = (asset: Media.Asset, provider: ProviderID) =>
Effect.map((item) => (item.type === "ref" ? { file_id: item.value } : { image_url: item.value })),
)

/**
* How input images travel. `edits` is OpenAI's `/images/edits`; `references` keeps every request on
* `/images/generations` and lists them as `input_references`, for gateways without an edits endpoint (OpenRouter).
*/
export type OpenAIImageInput = "edits" | "references"

type Options = { readonly sizing: OpenAIImageSizing; readonly imageInput: OpenAIImageInput }

const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (
request: MediaProtocol.Addressed<Request>,
sizing: OpenAIImageSizing,
options: Options,
) {
const images = request.images ?? []
const mask = request.providerOptions?.mask
if (options.imageInput === "references" && mask !== undefined)
return yield* route.unsupported("media.mask", `${request.model.provider} image edits take no mask`)
if (mask !== undefined && images.length === 0)
return yield* ProviderShared.invalidRequest("An OpenAI image mask requires at least one input image")
if (request.providerOptions?.size !== undefined && ImageSizing.isRequested(request))
Expand All @@ -217,13 +227,32 @@ const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (
const fields = mergeJsonRecords(
{
n: request.n,
...sizing(request),
...options.sizing(request),
...(yield* streamOptions(request)),
},
nativeOptions(request.providerOptions),
request.http?.body,
)

if (options.imageInput === "references") {
const references = yield* Effect.forEach(images, (image) =>
ProviderShared.mediaReference(image, undefined, route.name),
)
return MediaProtocol.json(
mergeJsonRecords(
{
model: request.model.id,
prompt: request.prompt,
input_references:
references.length === 0
? undefined
: references.map((item) => ({ type: "image_url", image_url: { url: item.value } })),
},
fields,
) ?? {},
)
}

// Owned bytes go through multipart edits; remote URLs and file IDs use the JSON edits body instead.
if (images.length > 0 && images.every(isInline) && (mask === undefined || isInline(mask))) {
const form = new FormData()
Expand Down Expand Up @@ -359,9 +388,9 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
// 7. Protocol and route
// ---------------------------------------------------------------------------

const makeProtocol = (sizing: OpenAIImageSizing) =>
const makeProtocol = (options: Options) =>
MediaProtocol.stream<Request, ImageEvent, Frame, State>(route, {
body: { from: (request) => fromRequest(request, sizing) },
body: { from: (request) => fromRequest(request, options) },
frames: (bytes, context) =>
context.request.mode === "stream"
? Framing.sse.frame(bytes)
Expand All @@ -371,21 +400,29 @@ const makeProtocol = (sizing: OpenAIImageSizing) =>
finish,
})

export const protocol = makeProtocol(pixelSize)
export const protocol = makeProtocol({ sizing: pixelSize, imageInput: "edits" })

/**
* `provider` attributes models, refs, and metadata to an OpenAI-compatible host; it defaults to `openai`. `sizing`
* replaces the `size` that `aspect` and `resolution` lower to.
* replaces the `size` that `aspect` and `resolution` lower to, and `imageInput` how input images are sent.
*/
export const model = (
input: MediaRoute.ModelInput & { readonly provider?: string; readonly sizing?: OpenAIImageSizing },
input: MediaRoute.ModelInput & {
readonly provider?: string
readonly sizing?: OpenAIImageSizing
readonly imageInput?: OpenAIImageInput
},
) => {
const base = input.sizing === undefined ? protocol : makeProtocol(input.sizing)
const imageInput = input.imageInput ?? "edits"
const base =
input.sizing === undefined && imageInput === "edits"
? protocol
: makeProtocol({ sizing: input.sizing ?? pixelSize, imageInput })
return ImageModel.fromRoute<OpenAIImageOptions, Frame, State>(
{
protocol: input.provider === undefined ? base : { ...base, provider: ProviderID.make(input.provider) },
baseURL: DEFAULT_BASE_URL,
path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH),
path: ({ request }) => (imageInput === "edits" && isEdit(request) ? EDIT_PATH : PATH),
},
input,
)
Expand Down
9 changes: 8 additions & 1 deletion packages/ai/src/providers/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,14 @@ export const configure = (input: Config = {}) => {
})
const media = MediaRoute.deployment({ ...input, baseURL: input.baseURL ?? baseURL }, auth)
const image = (modelID: string | ModelID, providerOptions?: ProviderOptions) =>
OpenAIImages.model({ ...media, id: modelID, provider: id, providerOptions, sizing: imageSizing })
OpenAIImages.model({
...media,
id: modelID,
provider: id,
providerOptions,
sizing: imageSizing,
imageInput: "references",
})
const decision = (modelID: string | ModelID, providerOptions?: ProviderOptions) =>
decisionRoute.model<OpenRouterDecisionOptions>({
id: modelID,
Expand Down

Large diffs are not rendered by default.

54 changes: 54 additions & 0 deletions packages/ai/test/image.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,60 @@ describe("Image", () => {
),
)

describe("OpenRouter image inputs", () => {
const openrouter = OpenRouter.configure({ apiKey: "test", baseURL: "https://openrouter.test/v1" })
const sent = (expected: Record<string, unknown>) =>
layer((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.url).toBe("https://openrouter.test/v1/images/generations")
expect(request.headers.get("content-type")).toBe("application/json")
expect(JSON.parse(input.text)).toEqual(expected)
return json(input, { data: [{ b64_json: "AQID" }] })
}),
)

it.effect("sends a plain generation unchanged", () =>
Image.generate({ model: openrouter.image("openai/gpt-image-1-mini"), prompt: "A lighthouse" }).pipe(
Effect.provide(sent({ model: "openai/gpt-image-1-mini", prompt: "A lighthouse" })),
),
)

it.effect("sends input images as input references on the generations endpoint", () =>
Image.generate({
model: openrouter.image("openai/gpt-image-1-mini"),
prompt: "Combine these images",
images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), Media.url("https://example.test/source.png")],
providerOptions: { quality: "low" },
}).pipe(
Effect.provide(
sent({
model: "openai/gpt-image-1-mini",
prompt: "Combine these images",
input_references: [
{ type: "image_url", image_url: { url: "data:image/png;base64,AQID" } },
{ type: "image_url", image_url: { url: "https://example.test/source.png" } },
],
quality: "low",
}),
),
),
)

it.effect("rejects a mask", () =>
Effect.gen(function* () {
const error = yield* Image.generate({
model: openrouter.image("openai/gpt-image-1-mini"),
prompt: "Paint the masked area",
images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png")],
providerOptions: { mask: Media.bytes(Uint8Array.from([7, 8, 9]), "image/png") },
}).pipe(Effect.flip)
expect(error.reason._tag).toBe("UnsupportedOperation")
expect(error.message).toContain("openrouter image edits take no mask")
}).pipe(Effect.provide(layer(() => Effect.die("no request expected")))),
)
})

it.effect("routes ordered xAI image inputs through JSON edits", () =>
Image.generate({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
Expand Down
27 changes: 26 additions & 1 deletion packages/ai/test/provider/openrouter-images.recorded.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Image } from "../../src/effect.js"
import { Image, Media } from "../../src/effect.js"
import { OpenRouter } from "../../src/providers.js"
import { dimensions } from "../lib/image.js"
import { recordedTests } from "../recorded-test.js"
Expand Down Expand Up @@ -48,4 +48,29 @@ describe("OpenRouter Images recorded", () => {
}),
{ timeout: 120_000 },
)

// OpenRouter has no `/images/edits`; the image has to go out as an `input_references` data URL on the JSON body.
recorded.effect(
"edits an image through input references",
() =>
Effect.gen(function* () {
const response = yield* Image.generate({
model: openrouter.image("openai/gpt-image-1-mini"),
prompt: "Keep the simple shape and change it from black to bright green.",
images: [
Media.bytes(
yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
"image/jpeg",
),
],
aspect: "1:1",
resolution: "1K",
providerOptions: { outputFormat: "jpeg", quality: "low", outputCompression: 10 },
})

expect(response.image.mediaType).toBe("image/jpeg")
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 1024, height: 1024 })
}),
{ timeout: 120_000 },
)
})
Loading