diff --git a/packages/ai/README.md b/packages/ai/README.md index 4804da96f36..881abd1b99d 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -581,10 +581,12 @@ call rather than being dropped, so check this table before swapping only the `mo | Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ | | Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ | | Vercel AI Gateway | ✓ | per model | ✗ | ✗ | ignored | per model | per model | +| Cloudflare Workers AI | 1 | ✓² | ✗ | ✓² | ✗ | ✗ | ✗ | ✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model. Vercel AI Gateway -forwards `format` but does not apply it, so the returned bytes keep the upstream model's own format. +forwards `format` but does not apply it, so the returned bytes keep the upstream model's own format. ² Workers AI +`@cf/black-forest-labs/flux-1-schnell` takes neither `size` nor `seed`, and the provider rejects them. `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()`, diff --git a/packages/ai/src/protocols/cloudflare-workers-ai-images.ts b/packages/ai/src/protocols/cloudflare-workers-ai-images.ts new file mode 100644 index 00000000000..cab7e766a7b --- /dev/null +++ b/packages/ai/src/protocols/cloudflare-workers-ai-images.ts @@ -0,0 +1,105 @@ +import { Effect, Schema } from "effect" +import type { HttpClientResponse } from "effect/http" +import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js" +import { Media } from "../media.js" +import { MediaProtocol } from "../route/media-protocol.js" +import type { MediaRoute } from "../route/media.js" +import { mergeJsonRecords } from "../schema/index.js" +import { MediaInput } from "./utils/media-input.js" + +const route = MediaProtocol.identity({ + id: "cloudflare-workers-ai-images", + name: "Cloudflare Workers AI", + provider: "cloudflare-workers-ai", +}) +/** FLUX.2 models only accept multipart form fields, even for a prompt alone. */ +const MULTIPART_MODEL_PREFIX = "@cf/black-forest-labs/flux-2-" +const RESERVED_FORM_FIELDS = new Set(["prompt"]) + +// --------------------------------------------------------------------------- +// 1. Public model input +// --------------------------------------------------------------------------- + +/** Native Run API inputs. Step counts are `steps` on FLUX and Lucid Origin and `num_steps` on the other models. */ +export type CloudflareWorkersAIImageOptions = { + readonly steps?: number + readonly num_steps?: number + readonly guidance?: number + readonly negative_prompt?: string +} & Record + +export type Request = ImageRequestFor + +// --------------------------------------------------------------------------- +// 2. Response schema +// --------------------------------------------------------------------------- + +const RunResponse = Schema.Struct({ + result: Schema.Struct({ + image: Schema.optional(Schema.String), + usage: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }), +}) + +// --------------------------------------------------------------------------- +// 5. Request body construction +// --------------------------------------------------------------------------- + +const fromRequest = Effect.fn("CloudflareWorkersAIImages.fromRequest")(function* (request: Request) { + if (request.n !== undefined && request.n > 1) + return yield* route.unsupported("media.n", `${route.name} generates one image per request; call it once per image`) + const fields = { + prompt: request.prompt, + ...(request.size === undefined ? {} : MediaInput.dimensions(request.size)), + seed: request.seed, + } + const native = mergeJsonRecords(request.providerOptions, request.http?.body) + if (!request.model.id.startsWith(MULTIPART_MODEL_PREFIX)) + return MediaProtocol.json(mergeJsonRecords(fields, native) ?? {}) + const body = new FormData() + MediaInput.appendFields(body, fields, { overlay: native, reserved: RESERVED_FORM_FIELDS }) + return MediaProtocol.multipart(body) +}) + +// --------------------------------------------------------------------------- +// 6. Response decoding +// --------------------------------------------------------------------------- + +const decodeDocument = route.decodeJson(RunResponse) + +// FLUX and Lucid Origin wrap base64 in the Run API envelope; the Stable Diffusion family, DreamShaper, and Phoenix +// answer with the raw image, whose declared content type does not always match the bytes. +const decodeResponse = Effect.fn("CloudflareWorkersAIImages.decodeResponse")(function* ( + response: HttpClientResponse.HttpClientResponse, +) { + if (!response.headers["content-type"]?.startsWith("application/json")) + return new ImageResponse({ images: [Media.bytes(yield* route.bytes(response))] }) + const output = yield* decodeDocument(response) + const result = output.value.result + if (result.image === undefined) return yield* output.invalid(`${route.name} returned no image`) + return new ImageResponse({ + images: [yield* MediaInput.decodedAsset(output.invalid, `${route.name} result`, result.image, undefined)], + providerMetadata: result.usage === undefined ? undefined : { "cloudflare-workers-ai": { usage: result.usage } }, + }) +}) + +// --------------------------------------------------------------------------- +// 7. Protocol and route +// --------------------------------------------------------------------------- + +export const protocol = MediaProtocol.inline(route, { + unsupported: ["images", "mask", "aspectRatio", "format"], + body: { from: fromRequest }, + response: { decode: decodeResponse }, +}) + +export const model = (input: MediaRoute.ModelInput) => + ImageModel.fromRoute( + { protocol, path: ({ request }) => `/${request.model.id}` }, + input, + ) + +export const CloudflareWorkersAIImages = { + protocol, + model, +} as const diff --git a/packages/ai/src/providers/cloudflare-workers-ai.ts b/packages/ai/src/providers/cloudflare-workers-ai.ts index 9c893686414..318ca294325 100644 --- a/packages/ai/src/providers/cloudflare-workers-ai.ts +++ b/packages/ai/src/providers/cloudflare-workers-ai.ts @@ -1,10 +1,12 @@ import type { ProviderPackage } from "../provider-package.js" import { CloudflareRunEvaluation } from "../experimental/cloudflare-run-evaluation.js" import { EvaluationRounding } from "../experimental/evaluation.js" +import { CloudflareWorkersAIImages } from "../protocols/cloudflare-workers-ai-images.js" import { OpenAIChat } from "../protocols/openai-chat.js" import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js" import { Route, type RouteDefaultsInput } from "../route/client.js" import { Endpoint } from "../route/endpoint.js" +import { MediaRoute } from "../route/media.js" import { HttpOptions, ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js" import type { OpenAIProviderOptionsInput } from "./openai-options.js" @@ -28,6 +30,8 @@ export type Settings = ProviderPackage.Settings & readonly apiKey?: string } +export type { CloudflareWorkersAIImageOptions } from "../protocols/cloudflare-workers-ai-images.js" + export const baseURL = (input: WorkersAIURL) => { if (input.baseURL) return input.baseURL if (!input.accountId) @@ -80,6 +84,11 @@ export const configure = (input: LanguageModelOptions) => { id, model: (modelID: string | ModelID) => configured.model({ id: modelID }), experimental: { evaluation }, + image: (modelID: string | ModelID) => + CloudflareWorkersAIImages.model({ + ...MediaRoute.deployment({ ...input, baseURL: runBaseURL(input) }, auth), + id: modelID, + }), configure, } } diff --git a/packages/ai/src/route/media-protocol.ts b/packages/ai/src/route/media-protocol.ts index 615c4fa91e4..1aa55156cf4 100644 --- a/packages/ai/src/route/media-protocol.ts +++ b/packages/ai/src/route/media-protocol.ts @@ -206,6 +206,15 @@ export const identity = (input: { readonly id: string; readonly name: string; re const provider = ProviderID.make(input.provider) const frameError = (message: string, body?: string, cause?: unknown) => new AIError({ reason: new InvalidProviderOutputError({ route: input.id, message, body, cause }) }) + const readError = (http: HttpContext) => (cause: unknown) => + new AIError({ + reason: new InvalidProviderOutputError({ + route: input.id, + message: `Failed to read the ${input.name} response`, + http, + cause, + }), + }) /** * Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a @@ -216,19 +225,7 @@ export const identity = (input: { readonly id: string; readonly name: string; re */ const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) { const http = RequestExecutor.responseHttp(response) - const body = yield* response.text.pipe( - Effect.mapError( - (cause) => - new AIError({ - reason: new InvalidProviderOutputError({ - route: input.id, - message: `Failed to read the ${input.name} response`, - http, - cause, - }), - }), - ), - ) + const body = yield* response.text.pipe(Effect.mapError(readError(http))) return { body, http, @@ -269,11 +266,19 @@ export const identity = (input: { readonly id: string; readonly name: string; re }) } + /** Read a raw binary body, for providers that answer with the media bytes themselves. */ + const bytes = (response: HttpClientResponse.HttpClientResponse) => + response.arrayBuffer.pipe( + Effect.map((buffer) => new Uint8Array(buffer)), + Effect.mapError(readError(RequestExecutor.responseHttp(response))), + ) + return { id: input.id, name: input.name, provider, text, + bytes, decodeJson, /** Decode a submission response into the token and first snapshot. */ decodeStarted: (schema: Schema.Codec, started: (value: A) => Started) => { diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index d7fab8adcb6..14e57bf7b8a 100644 --- a/packages/ai/test/exports.test.ts +++ b/packages/ai/test/exports.test.ts @@ -188,6 +188,11 @@ describe("public exports", () => { ) expect(VercelAIGateway.configure({ apiKey: "fixture" }).image("meta/muse-image-1.0").route.id).toBe("openai-images") expect(VercelAIGateway.provider.image).toBe(VercelAIGateway.image) + expect( + CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).image( + "@cf/black-forest-labs/flux-1-schnell", + ).route.id, + ).toBe("cloudflare-workers-ai-images") expect(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video") expect(XAI.provider.video).toBe(XAI.video) expect(Google.configure({ apiKey: "fixture" }).video("veo-3.1-generate-preview").route.id).toBe("google-video") diff --git a/packages/ai/test/fixtures/recordings/cloudflare-workers-ai-images/generates-a-flux-2-image-from-multipart-fields.json b/packages/ai/test/fixtures/recordings/cloudflare-workers-ai-images/generates-a-flux-2-image-from-multipart-fields.json new file mode 100644 index 00000000000..2c20f497e6a --- /dev/null +++ b/packages/ai/test/fixtures/recordings/cloudflare-workers-ai-images/generates-a-flux-2-image-from-multipart-fields.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:cloudflare-workers-ai-images", + "provider:cloudflare-workers-ai", + "protocol:cloudflare-workers-ai-images" + ], + "name": "cloudflare-workers-ai-images/generates-a-flux-2-image-from-multipart-fields", + "recordedAt": "2026-10-23T07:50:08.675Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/run/@cf/black-forest-labs/flux-2-klein-4b", + "headers": { + "content-type": "multipart/form-data; boundary=----WebKitFormBoundary7f319b8533aa40cabcb669acb955728d" + }, + "body": "------WebKitFormBoundary7f319b8533aa40cabcb669acb955728d\r\nContent-Disposition: form-data; name=\"prompt\"\r\n\r\nA red apple on a wooden table\r\n------WebKitFormBoundary7f319b8533aa40cabcb669acb955728d\r\nContent-Disposition: form-data; name=\"width\"\r\n\r\n64\r\n------WebKitFormBoundary7f319b8533aa40cabcb669acb955728d\r\nContent-Disposition: form-data; name=\"height\"\r\n\r\n64\r\n------WebKitFormBoundary7f319b8533aa40cabcb669acb955728d\r\nContent-Disposition: form-data; name=\"seed\"\r\n\r\n3\r\n------WebKitFormBoundary7f319b8533aa40cabcb669acb955728d\r\nContent-Disposition: form-data; name=\"steps\"\r\n\r\n1\r\n------WebKitFormBoundary7f319b8533aa40cabcb669acb955728d--\r\n" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"result\":{\"image\":\"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\"},\"success\":true,\"errors\":[],\"messages\":[]}" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/cloudflare-workers-ai-images/generates-a-raw-binary-image.json b/packages/ai/test/fixtures/recordings/cloudflare-workers-ai-images/generates-a-raw-binary-image.json new file mode 100644 index 00000000000..de8eea3301e --- /dev/null +++ b/packages/ai/test/fixtures/recordings/cloudflare-workers-ai-images/generates-a-raw-binary-image.json @@ -0,0 +1,33 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:cloudflare-workers-ai-images", + "provider:cloudflare-workers-ai", + "protocol:cloudflare-workers-ai-images" + ], + "name": "cloudflare-workers-ai-images/generates-a-raw-binary-image", + "recordedAt": "2026-10-23T07:50:02.769Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.cloudflare.com/client/v4/accounts/{account}/ai/run/@cf/bytedance/stable-diffusion-xl-lightning", + "headers": { + "content-type": "application/json" + }, + "body": "{\"prompt\":\"A red apple on a wooden table\",\"width\":256,\"height\":256,\"seed\":3,\"num_steps\":1}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "image/png" + }, + "body": "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", + "bodyEncoding": "base64" + } + } + ] +} diff --git a/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts b/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts new file mode 100644 index 00000000000..24725e99cd7 --- /dev/null +++ b/packages/ai/test/provider/cloudflare-workers-ai-images.recorded.test.ts @@ -0,0 +1,59 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Image } from "../../src/index.js" +import { CloudflareWorkersAI } from "../../src/providers.js" +import { dimensions } from "../lib/image.js" +import { recordedTests } from "../recorded-test.js" + +const workers = CloudflareWorkersAI.configure({ + accountId: process.env.CLOUDFLARE_ACCOUNT_ID ?? "fixture-account", + apiKey: process.env.CLOUDFLARE_API_KEY ?? "fixture", +}) + +const recorded = recordedTests({ + prefix: "cloudflare-workers-ai-images", + provider: "cloudflare-workers-ai", + protocol: "cloudflare-workers-ai-images", + requires: ["CLOUDFLARE_ACCOUNT_ID", "CLOUDFLARE_API_KEY"], + options: { + redact: { url: (url) => url.replace(/\/accounts\/[^/]+\/ai\//, "/accounts/{account}/ai/") }, + // Multipart boundaries change on every request, so bodies cannot be matched. + match: (incoming, recorded) => incoming.method === recorded.method && incoming.url === recorded.url, + }, +}) + +describe("Cloudflare Workers AI Images recorded", () => { + recorded.effect("generates a raw binary image", () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model: workers.image("@cf/bytedance/stable-diffusion-xl-lightning"), + prompt: "A red apple on a wooden table", + size: "256x256", + seed: 3, + providerOptions: { num_steps: 1 }, + }) + + // The Run API labels this response image/png, but the bytes are JPEG. + expect(response.image.mediaType).toBe("image/jpeg") + expect(dimensions(yield* response.image.bytes())).toEqual({ width: 256, height: 256 }) + }), + ) + + recorded.effect( + "generates a FLUX.2 image from multipart fields", + () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model: workers.image("@cf/black-forest-labs/flux-2-klein-4b"), + prompt: "A red apple on a wooden table", + size: "64x64", + seed: 3, + providerOptions: { steps: 1 }, + }) + + expect(response.image.mediaType).toBe("image/jpeg") + expect(dimensions(yield* response.image.bytes())).toEqual({ width: 64, height: 64 }) + }), + { timeout: 60_000 }, + ) +}) diff --git a/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts b/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts new file mode 100644 index 00000000000..efeac9b8b0a --- /dev/null +++ b/packages/ai/test/provider/cloudflare-workers-ai-images.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClientRequest } from "effect/http" +import { Image, ImageClient, Media } from "../../src/index.js" +import { CloudflareWorkersAI } from "../../src/providers.js" +import { ProviderConfigurationError } from "../../src/schema/index.js" +import { it } from "../lib/effect.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" + +const workers = CloudflareWorkersAI.configure({ accountId: "test-account", apiKey: "test" }) + +describe("Cloudflare Workers AI Images", () => { + test("derives the Run API base only from an /ai/v1 base", () => { + expect(CloudflareWorkersAI.runBaseURL({ baseURL: "https://proxy.test/client/v4/accounts/a/ai/v1" })).toBe( + "https://proxy.test/client/v4/accounts/a/ai/run", + ) + const gateway = CloudflareWorkersAI.configure({ + baseURL: "https://gateway.ai.cloudflare.com/v1/a/g/workers-ai/v1", + apiKey: "test", + }) + expect(() => gateway.image("@cf/black-forest-labs/flux-1-schnell")).toThrow(ProviderConfigurationError) + }) + + it.effect("posts JSON inputs to the Run API and decodes the base64 envelope", () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model: workers.image("@cf/leonardo/lucid-origin"), + prompt: "A red apple", + size: "512x256", + seed: 7, + providerOptions: { steps: 4, guidance: 3 }, + http: { body: { guidance: 5 } }, + }) + + expect(response.image.mediaType).toBe("image/png") + expect(response.providerMetadata).toEqual({ "cloudflare-workers-ai": { usage: { neurons: 12 } } }) + }).pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie) + expect(request.url).toBe( + "https://api.cloudflare.com/client/v4/accounts/test-account/ai/run/@cf/leonardo/lucid-origin", + ) + expect(request.headers.get("authorization")).toBe("Bearer test") + expect(JSON.parse(input.text)).toEqual({ + prompt: "A red apple", + width: 512, + height: 256, + seed: 7, + steps: 4, + guidance: 5, + }) + return input.respond( + JSON.stringify({ + result: { image: "iVBORw0KGgo=", usage: { neurons: 12 } }, + success: true, + errors: [], + messages: [], + }), + { headers: { "content-type": "application/json" } }, + ) + }), + ), + ), + ), + ), + ), + ) + + it.effect("rejects an envelope without an image", () => + Image.generate({ model: workers.image("@cf/black-forest-labs/flux-1-schnell"), prompt: "test" }).pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + fixedResponse(JSON.stringify({ result: {}, success: true, errors: [], messages: [] }), { + headers: { "content-type": "application/json" }, + }), + ), + ), + ), + Effect.flip, + Effect.map((error) => expect(error.reason._tag).toBe("InvalidProviderOutput")), + ), + ) + + it.effect("fails typed for fields the Run API image models cannot take", () => + Effect.gen(function* () { + const model = workers.image("@cf/black-forest-labs/flux-2-dev") + const errors = yield* Effect.forEach( + [ + { model, prompt: "test", n: 2 }, + { model, prompt: "test", images: [Media.bytes(new Uint8Array([1]), "image/png")] }, + ], + (request) => Image.generate(request).pipe(Effect.flip), + ) + expect(errors.map((error) => error.reason._tag)).toEqual(["UnsupportedOperation", "UnsupportedOperation"]) + }).pipe(Effect.provide(ImageClient.layer.pipe(Layer.provide(fixedResponse("unused"))))), + ) +})