| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578 |
- import { describe, expect } from "bun:test"
- import { Effect, Layer } from "effect"
- import { HttpClientRequest } from "effect/unstable/http"
- import { Image, ImageClient, ImageInput } from "../src"
- import { Google, OpenAI, XAI, ZAI } from "../src/providers"
- import { it } from "./lib/effect"
- import { dynamicResponse } from "./lib/http"
- describe("Image", () => {
- 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",
- options: {
- n: 2,
- size: "2048x2048",
- quality: "future-quality",
- outputFormat: "jpeg",
- 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?.data).toEqual(Uint8Array.from([1, 2, 3]))
- expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
- expect(response.usage?.totalTokens).toBe(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.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", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
- output_format: "webp",
- usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
- }),
- { headers: { "content-type": "application/json" } },
- )
- }),
- ),
- ),
- ),
- ),
- ),
- )
- 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",
- options: {
- outputFormat: "jpeg",
- 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: [
- ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
- ImageInput.url("data:image/jpeg;base64,BAUG"),
- ],
- options: {
- mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
- 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: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
- options: { mask: ImageInput.file("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: [
- ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
- ImageInput.url("https://example.test/source.jpg"),
- ImageInput.file("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: [ImageInput.file("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("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: [
- ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
- ImageInput.url("data:image/jpeg;base64,BAUG"),
- ImageInput.fileUri("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: [ImageInput.url("https://example.test/image.png")],
- }),
- Image.generate({
- model: ZAI.configure({ apiKey: "test" }).image("model"),
- prompt: "edit",
- images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
- }),
- ]
- yield* Effect.forEach(cases, (program) =>
- program.pipe(
- Effect.flip,
- Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
- ),
- )
- }).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",
- options: {
- aspectRatio: "16:9",
- imageSize: "2K",
- seed: 42,
- 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(response.images.map((image) => image.data)).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?.inputTokens).toBe(5)
- expect(response.usage?.outputTokens).toBe(10)
- expect(response.usage?.reasoningTokens).toBe(3)
- expect(response.usage?.providerMetadata).toMatchObject({ 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.provide(
- 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("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.reason.message).toContain("finish reasons: IMAGE_SAFETY")
- expect(error.reason.providerMetadata).toEqual({
- google: {
- promptFeedback: { blockReason: "SAFETY" },
- candidates: [
- {
- index: 0,
- finishReason: "IMAGE_SAFETY",
- finishMessage: "The generated image was blocked by safety filters.",
- safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
- citationMetadata: undefined,
- groundingMetadata: undefined,
- parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
- },
- ],
- },
- })
- }),
- ),
- 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" } },
- ),
- ),
- ),
- ),
- ),
- ),
- ),
- )
- })
|