mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 03:56:55 +03:00
feat(ai): add Cloudflare Workers AI image generation via the Run API (#58387)
This commit is contained in:
committed by
GitHub
Unverified
parent
f6e202cb7b
commit
4386838922
@@ -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()`,
|
||||
|
||||
@@ -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<string, unknown>
|
||||
|
||||
export type Request = ImageRequestFor<CloudflareWorkersAIImageOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Request, ImageResponse>(route, {
|
||||
unsupported: ["images", "mask", "aspectRatio", "format"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
ImageModel.fromRoute<CloudflareWorkersAIImageOptions>(
|
||||
{ protocol, path: ({ request }) => `/${request.model.id}` },
|
||||
input,
|
||||
)
|
||||
|
||||
export const CloudflareWorkersAIImages = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -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<OpenAIProviderOptionsInput>({ id: modelID }),
|
||||
experimental: { evaluation },
|
||||
image: (modelID: string | ModelID) =>
|
||||
CloudflareWorkersAIImages.model({
|
||||
...MediaRoute.deployment({ ...input, baseURL: runBaseURL(input) }, auth),
|
||||
id: modelID,
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: <A, Token>(schema: Schema.Codec<A, unknown>, started: (value: A) => Started<Token>) => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
+32
File diff suppressed because one or more lines are too long
Vendored
+33
File diff suppressed because one or more lines are too long
@@ -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 },
|
||||
)
|
||||
})
|
||||
@@ -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"))))),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user