mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
951 lines
37 KiB
TypeScript
951 lines
37 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Effect, Layer, Stream } from "effect"
|
|
import { HttpClientRequest } from "effect/unstable/http"
|
|
import { Image, ImageClient, Media } from "../src/index.js"
|
|
import { BlackForestLabs, Fal, Google, OpenAI, Replicate, Stability, XAI, ZAI } from "../src/providers.js"
|
|
import { it } from "./lib/effect.js"
|
|
import { dynamicResponse, json } from "./lib/http.js"
|
|
|
|
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
|
|
ImageClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
|
|
|
describe("Image", () => {
|
|
for (const provider of [OpenAI, Google, XAI, ZAI]) {
|
|
const model = provider.configure({ apiKey: "test", baseURL: "https://image.test" }).image("image-model")
|
|
for (const body of ['{"data":42,"candidates":42,"opaque":{"nested":[1,2]},"trace":"outer"}', '{"invalid":']) {
|
|
it.effect(`retains ${model.provider} image response body and decode cause: ${body}`, () =>
|
|
Effect.gen(function* () {
|
|
const error = yield* Image.generate({ model, prompt: "hello" }).pipe(Effect.flip)
|
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
|
expect(error.message).toContain("invalid response")
|
|
expect(error.reason.body).toBe(body)
|
|
expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-image-trace": "trace-1" } })
|
|
expect(error.reason.http?.url).toStartWith("https://image.test/")
|
|
expect(error.reason.cause).toBeInstanceOf(Error)
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) =>
|
|
Effect.succeed(
|
|
input.respond(body, {
|
|
headers: { "content-type": "application/json", "x-image-trace": "trace-1" },
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
it.effect("generates images through the OpenAI Images API", () =>
|
|
Effect.gen(function* () {
|
|
const response = yield* Image.generate({
|
|
model: OpenAI.configure({
|
|
apiKey: "test",
|
|
baseURL: "https://api.openai.test/v1",
|
|
queryParams: { "api-version": "v1" },
|
|
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
|
}).image("gpt-image-2"),
|
|
prompt: "A robot tending a rooftop garden",
|
|
n: 2,
|
|
size: "2048x2048",
|
|
format: "jpeg",
|
|
providerOptions: {
|
|
quality: "future-quality",
|
|
output_format: "avif",
|
|
outputCompression: 30,
|
|
output_compression: 40,
|
|
background: "opaque",
|
|
native_default: true,
|
|
future_option: true,
|
|
},
|
|
http: {
|
|
body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
|
|
headers: { "x-request": "yes" },
|
|
query: { trace: "1" },
|
|
},
|
|
})
|
|
|
|
expect(response.images).toHaveLength(2)
|
|
expect(response.image.mediaType).toBe("image/webp")
|
|
expect(response.image.source).toEqual({
|
|
type: "bytes",
|
|
data: Uint8Array.from([1, 2, 3]),
|
|
mediaType: "image/webp",
|
|
})
|
|
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
|
|
expect(response.image.info).toEqual({ format: "webp", width: 2048, height: 2048 })
|
|
expect(response.usage).toMatchObject({ type: "tokens", total: 12 })
|
|
expect(response.providerMetadata).toEqual({
|
|
openai: { outputFormat: "webp", size: "2048x2048", quality: "high", background: "opaque" },
|
|
})
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provideMerge(
|
|
dynamicResponse((input) =>
|
|
Effect.gen(function* () {
|
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
|
expect(request.url).toBe("https://api.openai.test/v1/images/generations?api-version=v1&trace=1")
|
|
expect(request.headers.get("authorization")).toBe("Bearer test")
|
|
expect(request.headers.get("x-default")).toBe("yes")
|
|
expect(request.headers.get("x-request")).toBe("yes")
|
|
expect(JSON.parse(input.text)).toEqual({
|
|
model: "gpt-image-2",
|
|
prompt: "A robot tending a rooftop garden",
|
|
n: 2,
|
|
size: "2048x2048",
|
|
quality: "future-quality",
|
|
background: "opaque",
|
|
output_format: "webp",
|
|
output_compression: 50,
|
|
native_default: true,
|
|
future_option: "http",
|
|
deployment: "test",
|
|
request_metadata: "value",
|
|
})
|
|
return input.respond(
|
|
JSON.stringify({
|
|
data: [{ b64_json: "AQID" }, { b64_json: "BAUG" }],
|
|
output_format: "webp",
|
|
size: "2048x2048",
|
|
quality: "high",
|
|
background: "opaque",
|
|
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("sends only model and prompt by default and decodes OpenAI bytes as png", () =>
|
|
Effect.gen(function* () {
|
|
const openai = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" })
|
|
expect(openai.image("gpt-image-2").route.id).toBe("openai-images")
|
|
const response = yield* Image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) =>
|
|
Effect.gen(function* () {
|
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
|
expect(web.url).toBe("https://openai.test/v1/images/generations")
|
|
expect(JSON.parse(input.text)).toEqual({ model: "gpt-image-2", prompt: "A lighthouse" })
|
|
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
expect(response.image.source).toEqual({ type: "bytes", data: Uint8Array.from([1, 2, 3]), mediaType: "image/png" })
|
|
expect(response.image.info).toEqual({ format: "png" })
|
|
}),
|
|
)
|
|
|
|
it.effect("preserves native snake_case and unknown request options", () =>
|
|
Image.generate({
|
|
model: OpenAI.configure({
|
|
apiKey: "test",
|
|
baseURL: "https://api.openai.test/v1",
|
|
}).image("future-image-model"),
|
|
prompt: "A lighthouse in fog",
|
|
format: "jpeg",
|
|
providerOptions: {
|
|
output_format: "avif",
|
|
outputCompression: 30,
|
|
output_compression: 40,
|
|
provider_future_option: { enabled: true },
|
|
},
|
|
}).pipe(
|
|
Effect.tap((response) =>
|
|
Effect.sync(() => {
|
|
expect(response.image.mediaType).toBe("image/avif")
|
|
}),
|
|
),
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) => {
|
|
expect(JSON.parse(input.text)).toEqual({
|
|
model: "future-image-model",
|
|
prompt: "A lighthouse in fog",
|
|
output_format: "avif",
|
|
output_compression: 40,
|
|
provider_future_option: { enabled: true },
|
|
})
|
|
return Effect.succeed(
|
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
|
|
Image.generate({
|
|
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
|
prompt: "Combine these images",
|
|
images: [Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"), Media.fromDataUrl("data:image/jpeg;base64,BAUG")],
|
|
mask: Media.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
|
|
providerOptions: {
|
|
quality: "high",
|
|
future_option: true,
|
|
},
|
|
http: {
|
|
body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
|
|
headers: { "content-type": "application/json" },
|
|
},
|
|
}).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.openai.test/v1/images/edits")
|
|
expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
|
|
expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
|
|
expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
|
|
expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
|
|
expect(input.text).toContain('name="mask"')
|
|
expect(input.text).toContain('name="quality"\r\n\r\nlow')
|
|
expect(input.text).not.toContain("corrupt")
|
|
return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
|
headers: { "content-type": "application/json" },
|
|
})
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
|
|
Image.generate({
|
|
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
|
|
prompt: "Combine these images",
|
|
images: [Media.url("https://example.test/source.png"), Media.ref("openai", "file_123")],
|
|
mask: Media.ref("openai", "file_mask"),
|
|
http: { body: { future_option: true } },
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) => {
|
|
expect(JSON.parse(input.text)).toEqual({
|
|
model: "future-model",
|
|
prompt: "Combine these images",
|
|
images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
|
|
mask: { file_id: "file_mask" },
|
|
future_option: true,
|
|
})
|
|
return Effect.succeed(
|
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
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"),
|
|
prompt: "Combine these images",
|
|
images: [
|
|
Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
|
Media.url("https://example.test/source.jpg"),
|
|
Media.ref("xai", "file_123"),
|
|
],
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) => {
|
|
expect(JSON.parse(input.text)).toEqual({
|
|
model: "future-model",
|
|
prompt: "Combine these images",
|
|
images: [
|
|
{ url: "data:image/png;base64,AQID", type: "image_url" },
|
|
{ url: "https://example.test/source.jpg", type: "image_url" },
|
|
{ file_id: "file_123" },
|
|
],
|
|
})
|
|
return Effect.succeed(
|
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("uses xAI's singular image field for one input", () =>
|
|
Image.generate({
|
|
model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
|
|
prompt: "Edit this image",
|
|
images: [Media.ref("xai", "file_123")],
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) => {
|
|
expect(JSON.parse(input.text)).toEqual({
|
|
model: "future-model",
|
|
prompt: "Edit this image",
|
|
image: { file_id: "file_123" },
|
|
})
|
|
return Effect.succeed(
|
|
input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("decodes URL images and rejects items with neither data nor a URL", () =>
|
|
Effect.gen(function* () {
|
|
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model")
|
|
const respond = (data: ReadonlyArray<object>) =>
|
|
layer((input) =>
|
|
Effect.succeed(input.respond(JSON.stringify({ data }), { headers: { "content-type": "application/json" } })),
|
|
)
|
|
const response = yield* Image.generate({ model, prompt: "A kite" }).pipe(
|
|
Effect.provide(respond([{ url: "https://xai.test/a.png", mime_type: "image/png" }])),
|
|
)
|
|
expect(response.images[0].source).toEqual({ type: "url", url: "https://xai.test/a.png", mediaType: "image/png" })
|
|
const error = yield* Image.generate({ model, prompt: "A kite" }).pipe(Effect.provide(respond([{}])), Effect.flip)
|
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
|
expect(error.message).toContain("xAI Images result 0 has neither image data nor a URL")
|
|
}),
|
|
)
|
|
|
|
it.effect("lowers ordered Google image inputs into generateContent parts", () =>
|
|
Image.generate({
|
|
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
|
|
prompt: "Combine these images",
|
|
images: [
|
|
Media.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
|
|
Media.fromDataUrl("data:image/jpeg;base64,BAUG"),
|
|
Media.ref("google", "https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
|
|
],
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) => {
|
|
expect(JSON.parse(input.text).contents[0].parts).toEqual([
|
|
{ text: "Combine these images" },
|
|
{ inlineData: { mimeType: "image/png", data: "AQID" } },
|
|
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
|
{
|
|
fileData: {
|
|
mimeType: "image/webp",
|
|
fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
|
|
},
|
|
},
|
|
])
|
|
return Effect.succeed(
|
|
input.respond(
|
|
JSON.stringify({
|
|
candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
),
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("rejects unsupported provider inputs before sending", () =>
|
|
Effect.gen(function* () {
|
|
const cases = [
|
|
Image.generate({
|
|
model: Google.configure({ apiKey: "test" }).image("model"),
|
|
prompt: "edit",
|
|
images: [Media.url("https://example.test/image.png")],
|
|
}),
|
|
Image.generate({
|
|
model: ZAI.configure({ apiKey: "test" }).image("model"),
|
|
prompt: "edit",
|
|
images: [Media.bytes(Uint8Array.from([1]), "image/png")],
|
|
}),
|
|
]
|
|
yield* Effect.forEach(cases, (program) =>
|
|
program.pipe(
|
|
Effect.flip,
|
|
Effect.tap((error) =>
|
|
Effect.sync(() => expect(["InvalidRequest", "UnsupportedOperation"]).toContain(error.reason._tag)),
|
|
),
|
|
),
|
|
)
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("generates images through the Google generateContent API", () =>
|
|
Effect.gen(function* () {
|
|
const response = yield* Image.generate({
|
|
model: Google.configure({
|
|
apiKey: "test",
|
|
baseURL: "https://generativelanguage.test/v1beta/",
|
|
headers: { "x-default": "yes" },
|
|
http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
|
|
}).image("any-model-id"),
|
|
prompt: "A robot tending a rooftop garden",
|
|
aspectRatio: "16:9",
|
|
seed: 42,
|
|
providerOptions: {
|
|
imageSize: "2K",
|
|
thinkingLevel: "HIGH",
|
|
includeThoughts: true,
|
|
futureOption: true,
|
|
imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
|
|
thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
|
|
},
|
|
http: {
|
|
body: {
|
|
safetySettings: [],
|
|
generationConfig: {
|
|
imageConfig: { aspectRatio: "3:2", httpImageOption: true },
|
|
thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
|
|
futureOption: "http",
|
|
httpOption: true,
|
|
},
|
|
},
|
|
headers: { "x-request": "yes" },
|
|
query: { trace: "1" },
|
|
},
|
|
})
|
|
|
|
expect(response.images).toHaveLength(3)
|
|
expect(yield* Effect.forEach(response.images, (image) => image.bytes())).toEqual([
|
|
Uint8Array.from([1, 2, 3]),
|
|
Uint8Array.from([4, 5, 6]),
|
|
Uint8Array.from([7, 8, 9]),
|
|
])
|
|
expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
|
|
expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
|
|
expect(response.images[1].providerMetadata).toMatchObject({
|
|
google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
|
|
})
|
|
expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
|
|
expect(response.usage).toMatchObject({
|
|
type: "tokens",
|
|
input: 5,
|
|
output: 10,
|
|
details: { reasoningTokens: 3, google: { serviceTier: "STANDARD" } },
|
|
})
|
|
expect(response.providerMetadata).toEqual({
|
|
google: {
|
|
modelVersion: "gemini-3.1-flash-image",
|
|
responseId: "response-1",
|
|
promptFeedback: undefined,
|
|
candidates: [
|
|
{
|
|
index: 0,
|
|
finishReason: "STOP",
|
|
finishMessage: undefined,
|
|
safetyRatings: [{ category: "safe" }],
|
|
citationMetadata: undefined,
|
|
groundingMetadata: undefined,
|
|
parts: [
|
|
{
|
|
type: "inlineData",
|
|
mediaType: "image/png",
|
|
thought: undefined,
|
|
thoughtSignature: "signature-1",
|
|
},
|
|
{ type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
|
|
{
|
|
type: "inlineData",
|
|
mediaType: "image/png",
|
|
thought: true,
|
|
thoughtSignature: "draft-signature",
|
|
},
|
|
{
|
|
type: "inlineData",
|
|
mediaType: "image/jpeg",
|
|
thought: undefined,
|
|
thoughtSignature: undefined,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
index: 7,
|
|
finishReason: undefined,
|
|
finishMessage: undefined,
|
|
safetyRatings: undefined,
|
|
citationMetadata: undefined,
|
|
groundingMetadata: undefined,
|
|
parts: [
|
|
{
|
|
type: "inlineData",
|
|
mediaType: "image/webp",
|
|
thought: undefined,
|
|
thoughtSignature: undefined,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
})
|
|
}).pipe(
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provideMerge(
|
|
dynamicResponse((input) =>
|
|
Effect.gen(function* () {
|
|
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
|
expect(request.url).toBe(
|
|
"https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
|
|
)
|
|
expect(request.headers.get("x-goog-api-key")).toBe("test")
|
|
expect(request.headers.get("x-default")).toBe("yes")
|
|
expect(request.headers.get("x-request")).toBe("yes")
|
|
expect(JSON.parse(input.text)).toEqual({
|
|
contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
|
|
generationConfig: {
|
|
responseModalities: ["IMAGE"],
|
|
imageConfig: {
|
|
aspectRatio: "3:2",
|
|
imageSize: "2K",
|
|
nativeImageOption: true,
|
|
httpImageOption: true,
|
|
},
|
|
seed: 42,
|
|
thinkingConfig: {
|
|
thinkingLevel: "LOW",
|
|
includeThoughts: false,
|
|
nativeThinkingOption: true,
|
|
httpThinkingOption: true,
|
|
},
|
|
futureOption: "http",
|
|
httpOption: true,
|
|
},
|
|
labels: { deployment: "test" },
|
|
safetySettings: [],
|
|
})
|
|
return input.respond(
|
|
JSON.stringify({
|
|
candidates: [
|
|
{
|
|
content: {
|
|
parts: [
|
|
{
|
|
inlineData: { mimeType: "image/png", data: "AQID" },
|
|
thoughtSignature: "signature-1",
|
|
},
|
|
{ text: "planning", thought: true, thoughtSignature: "text-signature" },
|
|
{
|
|
inlineData: { mimeType: "image/png", data: "CgsM" },
|
|
thought: true,
|
|
thoughtSignature: "draft-signature",
|
|
},
|
|
{ inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
|
|
],
|
|
},
|
|
finishReason: "STOP",
|
|
safetyRatings: [{ category: "safe" }],
|
|
},
|
|
{
|
|
index: 7,
|
|
content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
|
|
},
|
|
],
|
|
usageMetadata: {
|
|
promptTokenCount: 5,
|
|
candidatesTokenCount: 7,
|
|
thoughtsTokenCount: 3,
|
|
totalTokenCount: 15,
|
|
serviceTier: "STANDARD",
|
|
},
|
|
modelVersion: "gemini-3.1-flash-image",
|
|
responseId: "response-1",
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
)
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("surfaces filtered Google candidates as notices next to the returned image", () =>
|
|
Image.generate({
|
|
model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
|
|
"gemini-3.1-flash-image",
|
|
),
|
|
prompt: "A robot tending a rooftop garden",
|
|
}).pipe(
|
|
Effect.tap((response) =>
|
|
Effect.sync(() => {
|
|
expect(response.images).toHaveLength(1)
|
|
expect(response.notices).toEqual([
|
|
{
|
|
type: "filtered",
|
|
message: "Google Images reported prompt feedback",
|
|
providerMetadata: { google: { promptFeedback: { blockReason: "OTHER" } } },
|
|
},
|
|
{
|
|
type: "filtered",
|
|
message: "Google Images candidate 1 finished with IMAGE_SAFETY: Blocked.",
|
|
providerMetadata: {
|
|
google: {
|
|
candidateIndex: 1,
|
|
finishReason: "IMAGE_SAFETY",
|
|
finishMessage: "Blocked.",
|
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
|
},
|
|
},
|
|
},
|
|
])
|
|
}),
|
|
),
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) =>
|
|
Effect.succeed(
|
|
input.respond(
|
|
JSON.stringify({
|
|
promptFeedback: { blockReason: "OTHER" },
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] },
|
|
finishReason: "STOP",
|
|
},
|
|
{
|
|
index: 1,
|
|
content: { parts: [{ text: "blocked" }] },
|
|
finishReason: "IMAGE_SAFETY",
|
|
finishMessage: "Blocked.",
|
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
|
},
|
|
],
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("includes Google diagnostics when no final image is returned", () =>
|
|
Image.generate({
|
|
model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
|
|
"gemini-3.1-flash-image",
|
|
),
|
|
prompt: "A robot tending a rooftop garden",
|
|
}).pipe(
|
|
Effect.flip,
|
|
Effect.tap((error) =>
|
|
Effect.sync(() => {
|
|
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
|
if (error.reason._tag !== "InvalidProviderOutput") return
|
|
expect(error.message).toContain("finish reasons: IMAGE_SAFETY")
|
|
expect(JSON.parse(error.reason.body ?? "")).toEqual({
|
|
promptFeedback: { blockReason: "SAFETY" },
|
|
candidates: [
|
|
{
|
|
finishReason: "IMAGE_SAFETY",
|
|
finishMessage: "The generated image was blocked by safety filters.",
|
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
|
content: { parts: [{ text: "blocked", thought: false }] },
|
|
},
|
|
],
|
|
})
|
|
}),
|
|
),
|
|
Effect.provide(
|
|
ImageClient.layer.pipe(
|
|
Layer.provide(
|
|
dynamicResponse((input) =>
|
|
Effect.succeed(
|
|
input.respond(
|
|
JSON.stringify({
|
|
candidates: [
|
|
{
|
|
content: { parts: [{ text: "blocked", thought: false }] },
|
|
finishReason: "IMAGE_SAFETY",
|
|
finishMessage: "The generated image was blocked by safety filters.",
|
|
safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
|
|
},
|
|
],
|
|
promptFeedback: { blockReason: "SAFETY" },
|
|
}),
|
|
{ headers: { "content-type": "application/json" } },
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("rejects what a route cannot honor before sending anything", () =>
|
|
Effect.gen(function* () {
|
|
const openai = OpenAI.configure({ apiKey: "test" })
|
|
const replicate = Replicate.configure({ apiKey: "test" }).image("black-forest-labs/flux-schnell")
|
|
const prompt = "A lighthouse"
|
|
const errors = yield* Effect.all(
|
|
[
|
|
Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }),
|
|
Image.generate({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt, n: 2 }),
|
|
Image.start({
|
|
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
|
|
prompt,
|
|
aspectRatio: "16:9",
|
|
}),
|
|
Image.start({
|
|
model: Fal.configure({ apiKey: "test" }).image("fal-ai/nano-banana-2"),
|
|
prompt,
|
|
size: "512x512",
|
|
}),
|
|
Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })),
|
|
Image.start({ model: replicate, prompt, seed: 7 }),
|
|
Image.start({
|
|
model: replicate,
|
|
prompt,
|
|
providerOptions: { image: Media.bytes(new Uint8Array(300 * 1024), "image/png") },
|
|
}),
|
|
Image.start({ model: Stability.configure({ apiKey: "test" }).upscale(), prompt }),
|
|
].map((effect) => Effect.flip(effect)),
|
|
)
|
|
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
|
[
|
|
["UnsupportedOperation", "image.start"],
|
|
["UnsupportedOperation", "media.n"],
|
|
["UnsupportedOperation", "media.aspectRatio"],
|
|
["UnsupportedOperation", "media.size"],
|
|
["UnsupportedOperation", "media.n"],
|
|
["UnsupportedOperation", "media.seed"],
|
|
["InvalidRequest", false],
|
|
["InvalidRequest", false],
|
|
],
|
|
)
|
|
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
|
)
|
|
|
|
const falToken = {
|
|
requestID: "r1",
|
|
statusURL: "https://queue.fal.test/fal-ai/flux/requests/r1/status",
|
|
responseURL: "https://queue.fal.test/fal-ai/flux/requests/r1",
|
|
cancelURL: "https://queue.fal.test/fal-ai/flux/requests/r1/cancel",
|
|
}
|
|
const falSubmitted = {
|
|
request_id: falToken.requestID,
|
|
status_url: falToken.statusURL,
|
|
response_url: falToken.responseURL,
|
|
cancel_url: falToken.cancelURL,
|
|
}
|
|
const bodies: Array<unknown> = []
|
|
it.effect("sizes fal Kontext by aspect ratio and sends several images to /multi", () =>
|
|
Effect.gen(function* () {
|
|
const fal = Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" })
|
|
const images = [Media.url("https://example.test/a.png"), Media.url("https://example.test/b.png")]
|
|
const rejected = yield* Image.start({
|
|
model: fal.image("fal-ai/flux-pro/kontext"),
|
|
prompt: "A lighthouse",
|
|
size: "512x512",
|
|
}).pipe(Effect.flip)
|
|
yield* Image.start({
|
|
model: fal.image("fal-ai/flux-pro/kontext"),
|
|
prompt: "A lighthouse",
|
|
images: images.slice(0, 1),
|
|
aspectRatio: "16:9",
|
|
})
|
|
yield* Image.start({ model: fal.image("fal-ai/flux-pro/kontext/max/multi"), prompt: "A lighthouse", images })
|
|
|
|
expect(rejected.reason).toMatchObject({ _tag: "UnsupportedOperation", operation: "media.size" })
|
|
expect(bodies).toEqual([
|
|
{ prompt: "A lighthouse", aspect_ratio: "16:9", image_url: "https://example.test/a.png" },
|
|
{ prompt: "A lighthouse", image_urls: ["https://example.test/a.png", "https://example.test/b.png"] },
|
|
])
|
|
}).pipe(
|
|
Effect.provide(
|
|
layer((input) => {
|
|
bodies.push(JSON.parse(input.text))
|
|
return Effect.succeed(json(input, falSubmitted))
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
|
|
it.effect("decodes fal sync_mode data URIs as inline images", () =>
|
|
Effect.gen(function* () {
|
|
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
|
|
const response = yield* generation.await()
|
|
|
|
expect(response.images.map((image) => image.source)).toEqual([
|
|
{ type: "base64", data: "AQID", mediaType: "image/png" },
|
|
{ type: "url", url: "https://v3.fal.media/out.jpg", mediaType: "image/jpeg" },
|
|
])
|
|
expect(response.image.info).toEqual({ width: 512, height: 512 })
|
|
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
|
|
}).pipe(
|
|
Effect.provide(
|
|
layer((input) =>
|
|
Effect.succeed(
|
|
input.request.url === falToken.statusURL
|
|
? json(input, { status: "COMPLETED" })
|
|
: json(input, {
|
|
images: [
|
|
{ url: "data:image/png;base64,AQID", width: 512, height: 512, content_type: "image/png" },
|
|
{ url: "https://v3.fal.media/out.jpg", width: 512, height: 512, content_type: "image/jpeg" },
|
|
],
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
const falDetail = { detail: [{ loc: ["body", "prompt"], msg: "Invalid input", type: "value_error" }] }
|
|
it.effect(
|
|
"fails a fal await whose COMPLETED status carries an error with the response_url body and HTTP context",
|
|
() =>
|
|
Effect.gen(function* () {
|
|
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
|
|
expect(generation.status).toBe("failed")
|
|
const error = yield* generation.await().pipe(Effect.flip)
|
|
expect(error.reason._tag).toBe("InvalidRequest")
|
|
expect(error.reason.body).toBe(JSON.stringify(falDetail))
|
|
expect(error.reason.http).toMatchObject({ url: falToken.responseURL, status: 422 })
|
|
}).pipe(
|
|
Effect.provide(
|
|
layer((input) =>
|
|
Effect.succeed(
|
|
input.request.url === falToken.statusURL
|
|
? json(input, { status: "COMPLETED", error: "Invalid input", error_type: "ValidationError" })
|
|
: json(input, falDetail, { status: 422 }),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
|
|
const moderated = { id: "req_1", status: "Content Moderated" }
|
|
const prediction = {
|
|
id: "p_1",
|
|
status: "succeeded",
|
|
output: { text: "not an image" },
|
|
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
|
}
|
|
for (const pending of [
|
|
{
|
|
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
|
|
token: { id: "req_1", pollingURL: "https://bfl.test/v1/get_result?id=req_1" },
|
|
status: 200,
|
|
body: { id: "req_1", status: "Pending" },
|
|
message: "Black Forest Labs generation req_1",
|
|
},
|
|
{
|
|
model: Replicate.configure({ apiKey: "test" }).image("owner/model"),
|
|
token: { id: "p_1", getURL: "https://replicate.test/p_1", cancelURL: "https://replicate.test/p_1/cancel" },
|
|
status: 200,
|
|
body: {
|
|
id: "p_1",
|
|
status: "processing",
|
|
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
|
},
|
|
message: "Replicate generation p_1",
|
|
},
|
|
{
|
|
model: Stability.configure({ apiKey: "test", baseURL: "https://stability.test" }).upscale(),
|
|
token: { id: "up_1" },
|
|
status: 202,
|
|
body: { id: "up_1", status: "in-progress" },
|
|
message: "Stability AI generation up_1",
|
|
},
|
|
]) {
|
|
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
|
|
Effect.gen(function* () {
|
|
const generation = yield* Image.resume(pending.model, pending.token)
|
|
const error = yield* generation.result().pipe(Effect.flip)
|
|
expect(error.reason._tag).toBe("InvalidRequest")
|
|
expect(error.message).toBe(`${pending.message} has not finished; await it before reading the result`)
|
|
expect(error.reason.body).toBe(JSON.stringify(pending.body))
|
|
expect(error.reason.http?.status).toBe(pending.status)
|
|
}).pipe(
|
|
Effect.provide(
|
|
layer((input) =>
|
|
Effect.succeed(
|
|
input.respond(JSON.stringify(pending.body), {
|
|
status: pending.status,
|
|
headers: { "content-type": "application/json" },
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
}
|
|
|
|
it.effect("classifies terminal outcomes the recordings never saw", () =>
|
|
Effect.gen(function* () {
|
|
const bfl = yield* Image.resume(BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"), {
|
|
id: "req_1",
|
|
pollingURL: "https://bfl.test/v1/get_result?id=req_1",
|
|
}).pipe(
|
|
Effect.flatMap((generation) => generation.await()),
|
|
Effect.flip,
|
|
)
|
|
const replicate = yield* Image.generate({
|
|
model: Replicate.configure({ apiKey: "test", baseURL: "https://replicate.test" }).image("owner/model"),
|
|
prompt: "A lighthouse",
|
|
}).pipe(Effect.flip)
|
|
|
|
expect(bfl.reason).toMatchObject({ _tag: "ContentPolicy", body: JSON.stringify(moderated) })
|
|
expect(replicate.reason).toMatchObject({ _tag: "InvalidProviderOutput", body: JSON.stringify(prediction) })
|
|
}).pipe(
|
|
Effect.provide(
|
|
layer((input) =>
|
|
Effect.succeed(json(input, input.request.url.startsWith("https://bfl.test") ? moderated : prediction)),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
})
|