image.test.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. import { describe, expect } from "bun:test"
  2. import { Effect, Layer } from "effect"
  3. import { HttpClientRequest } from "effect/unstable/http"
  4. import { Image, ImageClient, ImageInput } from "../src"
  5. import { Google, OpenAI, XAI, ZAI } from "../src/providers"
  6. import { it } from "./lib/effect"
  7. import { dynamicResponse } from "./lib/http"
  8. describe("Image", () => {
  9. it.effect("generates images through the OpenAI Images API", () =>
  10. Effect.gen(function* () {
  11. const response = yield* Image.generate({
  12. model: OpenAI.configure({
  13. apiKey: "test",
  14. baseURL: "https://api.openai.test/v1",
  15. queryParams: { "api-version": "v1" },
  16. http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
  17. }).image("gpt-image-2"),
  18. prompt: "A robot tending a rooftop garden",
  19. options: {
  20. n: 2,
  21. size: "2048x2048",
  22. quality: "future-quality",
  23. outputFormat: "jpeg",
  24. output_format: "avif",
  25. outputCompression: 30,
  26. output_compression: 40,
  27. background: "opaque",
  28. native_default: true,
  29. future_option: true,
  30. },
  31. http: {
  32. body: { output_format: "webp", output_compression: 50, future_option: "http", request_metadata: "value" },
  33. headers: { "x-request": "yes" },
  34. query: { trace: "1" },
  35. },
  36. })
  37. expect(response.images).toHaveLength(2)
  38. expect(response.image?.mediaType).toBe("image/webp")
  39. expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
  40. expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
  41. expect(response.usage?.totalTokens).toBe(12)
  42. }).pipe(
  43. Effect.provide(
  44. ImageClient.layer.pipe(
  45. Layer.provide(
  46. dynamicResponse((input) =>
  47. Effect.gen(function* () {
  48. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  49. expect(request.url).toBe("https://api.openai.test/v1/images/generations?api-version=v1&trace=1")
  50. expect(request.headers.get("authorization")).toBe("Bearer test")
  51. expect(request.headers.get("x-default")).toBe("yes")
  52. expect(request.headers.get("x-request")).toBe("yes")
  53. expect(JSON.parse(input.text)).toEqual({
  54. model: "gpt-image-2",
  55. prompt: "A robot tending a rooftop garden",
  56. n: 2,
  57. size: "2048x2048",
  58. quality: "future-quality",
  59. background: "opaque",
  60. output_format: "webp",
  61. output_compression: 50,
  62. native_default: true,
  63. future_option: "http",
  64. deployment: "test",
  65. request_metadata: "value",
  66. })
  67. return input.respond(
  68. JSON.stringify({
  69. data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
  70. output_format: "webp",
  71. usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
  72. }),
  73. { headers: { "content-type": "application/json" } },
  74. )
  75. }),
  76. ),
  77. ),
  78. ),
  79. ),
  80. ),
  81. )
  82. it.effect("preserves native snake_case and unknown request options", () =>
  83. Image.generate({
  84. model: OpenAI.configure({
  85. apiKey: "test",
  86. baseURL: "https://api.openai.test/v1",
  87. }).image("future-image-model"),
  88. prompt: "A lighthouse in fog",
  89. options: {
  90. outputFormat: "jpeg",
  91. output_format: "avif",
  92. outputCompression: 30,
  93. output_compression: 40,
  94. provider_future_option: { enabled: true },
  95. },
  96. }).pipe(
  97. Effect.tap((response) =>
  98. Effect.sync(() => {
  99. expect(response.image?.mediaType).toBe("image/avif")
  100. }),
  101. ),
  102. Effect.provide(
  103. ImageClient.layer.pipe(
  104. Layer.provide(
  105. dynamicResponse((input) => {
  106. expect(JSON.parse(input.text)).toEqual({
  107. model: "future-image-model",
  108. prompt: "A lighthouse in fog",
  109. output_format: "avif",
  110. output_compression: 40,
  111. provider_future_option: { enabled: true },
  112. })
  113. return Effect.succeed(
  114. input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
  115. headers: { "content-type": "application/json" },
  116. }),
  117. )
  118. }),
  119. ),
  120. ),
  121. ),
  122. ),
  123. )
  124. it.effect("routes OpenAI byte inputs and masks through multipart edits", () =>
  125. Image.generate({
  126. model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
  127. prompt: "Combine these images",
  128. images: [
  129. ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
  130. ImageInput.url("data:image/jpeg;base64,BAUG"),
  131. ],
  132. options: {
  133. mask: ImageInput.bytes(Uint8Array.from([7, 8, 9]), "image/png"),
  134. quality: "high",
  135. future_option: true,
  136. },
  137. http: {
  138. body: { quality: "low", model: "corrupt", prompt: "corrupt", image: "corrupt", "image[]": "corrupt" },
  139. headers: { "content-type": "application/json" },
  140. },
  141. }).pipe(
  142. Effect.provide(
  143. ImageClient.layer.pipe(
  144. Layer.provide(
  145. dynamicResponse((input) =>
  146. Effect.gen(function* () {
  147. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  148. expect(request.url).toBe("https://api.openai.test/v1/images/edits")
  149. expect(request.headers.get("content-type")).toStartWith("multipart/form-data; boundary=")
  150. expect(input.text).toContain('name="model"\r\n\r\nfuture-model')
  151. expect(input.text).toContain('name="prompt"\r\n\r\nCombine these images')
  152. expect(input.text.match(/name="image\[\]"/g)).toHaveLength(2)
  153. expect(input.text).toContain('name="mask"')
  154. expect(input.text).toContain('name="quality"\r\n\r\nlow')
  155. expect(input.text).not.toContain("corrupt")
  156. return input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
  157. headers: { "content-type": "application/json" },
  158. })
  159. }),
  160. ),
  161. ),
  162. ),
  163. ),
  164. ),
  165. )
  166. it.effect("routes OpenAI URL and file inputs through JSON edits", () =>
  167. Image.generate({
  168. model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("future-model"),
  169. prompt: "Combine these images",
  170. images: [ImageInput.url("https://example.test/source.png"), ImageInput.file("file_123")],
  171. options: { mask: ImageInput.file("file_mask") },
  172. http: { body: { future_option: true } },
  173. }).pipe(
  174. Effect.provide(
  175. ImageClient.layer.pipe(
  176. Layer.provide(
  177. dynamicResponse((input) => {
  178. expect(JSON.parse(input.text)).toEqual({
  179. model: "future-model",
  180. prompt: "Combine these images",
  181. images: [{ image_url: "https://example.test/source.png" }, { file_id: "file_123" }],
  182. mask: { file_id: "file_mask" },
  183. future_option: true,
  184. })
  185. return Effect.succeed(
  186. input.respond(JSON.stringify({ data: [{ b64_json: "AQID" }] }), {
  187. headers: { "content-type": "application/json" },
  188. }),
  189. )
  190. }),
  191. ),
  192. ),
  193. ),
  194. ),
  195. )
  196. it.effect("routes ordered xAI image inputs through JSON edits", () =>
  197. Image.generate({
  198. model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
  199. prompt: "Combine these images",
  200. images: [
  201. ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
  202. ImageInput.url("https://example.test/source.jpg"),
  203. ImageInput.file("file_123"),
  204. ],
  205. }).pipe(
  206. Effect.provide(
  207. ImageClient.layer.pipe(
  208. Layer.provide(
  209. dynamicResponse((input) => {
  210. expect(JSON.parse(input.text)).toEqual({
  211. model: "future-model",
  212. prompt: "Combine these images",
  213. images: [
  214. { url: "data:image/png;base64,AQID", type: "image_url" },
  215. { url: "https://example.test/source.jpg", type: "image_url" },
  216. { file_id: "file_123" },
  217. ],
  218. })
  219. return Effect.succeed(
  220. input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
  221. headers: { "content-type": "application/json" },
  222. }),
  223. )
  224. }),
  225. ),
  226. ),
  227. ),
  228. ),
  229. )
  230. it.effect("uses xAI's singular image field for one input", () =>
  231. Image.generate({
  232. model: XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).image("future-model"),
  233. prompt: "Edit this image",
  234. images: [ImageInput.file("file_123")],
  235. }).pipe(
  236. Effect.provide(
  237. ImageClient.layer.pipe(
  238. Layer.provide(
  239. dynamicResponse((input) => {
  240. expect(JSON.parse(input.text)).toEqual({
  241. model: "future-model",
  242. prompt: "Edit this image",
  243. image: { file_id: "file_123" },
  244. })
  245. return Effect.succeed(
  246. input.respond(JSON.stringify({ data: [{ b64_json: "AQID", mime_type: "image/png" }] }), {
  247. headers: { "content-type": "application/json" },
  248. }),
  249. )
  250. }),
  251. ),
  252. ),
  253. ),
  254. ),
  255. )
  256. it.effect("lowers ordered Google image inputs into generateContent parts", () =>
  257. Image.generate({
  258. model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).image("future-model"),
  259. prompt: "Combine these images",
  260. images: [
  261. ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
  262. ImageInput.url("data:image/jpeg;base64,BAUG"),
  263. ImageInput.fileUri("https://generativelanguage.googleapis.com/v1beta/files/123", "image/webp"),
  264. ],
  265. }).pipe(
  266. Effect.provide(
  267. ImageClient.layer.pipe(
  268. Layer.provide(
  269. dynamicResponse((input) => {
  270. expect(JSON.parse(input.text).contents[0].parts).toEqual([
  271. { text: "Combine these images" },
  272. { inlineData: { mimeType: "image/png", data: "AQID" } },
  273. { inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
  274. {
  275. fileData: {
  276. mimeType: "image/webp",
  277. fileUri: "https://generativelanguage.googleapis.com/v1beta/files/123",
  278. },
  279. },
  280. ])
  281. return Effect.succeed(
  282. input.respond(
  283. JSON.stringify({
  284. candidates: [{ content: { parts: [{ inlineData: { mimeType: "image/png", data: "AQID" } }] } }],
  285. }),
  286. { headers: { "content-type": "application/json" } },
  287. ),
  288. )
  289. }),
  290. ),
  291. ),
  292. ),
  293. ),
  294. )
  295. it.effect("rejects unsupported provider inputs before sending", () =>
  296. Effect.gen(function* () {
  297. const cases = [
  298. Image.generate({
  299. model: Google.configure({ apiKey: "test" }).image("model"),
  300. prompt: "edit",
  301. images: [ImageInput.url("https://example.test/image.png")],
  302. }),
  303. Image.generate({
  304. model: ZAI.configure({ apiKey: "test" }).image("model"),
  305. prompt: "edit",
  306. images: [ImageInput.bytes(Uint8Array.from([1]), "image/png")],
  307. }),
  308. ]
  309. yield* Effect.forEach(cases, (program) =>
  310. program.pipe(
  311. Effect.flip,
  312. Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
  313. ),
  314. )
  315. }).pipe(
  316. Effect.provide(
  317. ImageClient.layer.pipe(
  318. Layer.provide(dynamicResponse(() => Effect.die("unsupported input reached the network"))),
  319. ),
  320. ),
  321. ),
  322. )
  323. it.effect("generates images through the Google generateContent API", () =>
  324. Effect.gen(function* () {
  325. const response = yield* Image.generate({
  326. model: Google.configure({
  327. apiKey: "test",
  328. baseURL: "https://generativelanguage.test/v1beta/",
  329. headers: { "x-default": "yes" },
  330. http: { body: { labels: { deployment: "test" } }, query: { api: "v1" } },
  331. }).image("any-model-id"),
  332. prompt: "A robot tending a rooftop garden",
  333. options: {
  334. aspectRatio: "16:9",
  335. imageSize: "2K",
  336. seed: 42,
  337. thinkingLevel: "HIGH",
  338. includeThoughts: true,
  339. futureOption: true,
  340. imageConfig: { aspectRatio: "4:3", nativeImageOption: true },
  341. thinkingConfig: { thinkingLevel: "LOW", nativeThinkingOption: true },
  342. },
  343. http: {
  344. body: {
  345. safetySettings: [],
  346. generationConfig: {
  347. imageConfig: { aspectRatio: "3:2", httpImageOption: true },
  348. thinkingConfig: { includeThoughts: false, httpThinkingOption: true },
  349. futureOption: "http",
  350. httpOption: true,
  351. },
  352. },
  353. headers: { "x-request": "yes" },
  354. query: { trace: "1" },
  355. },
  356. })
  357. expect(response.images).toHaveLength(3)
  358. expect(response.images.map((image) => image.data)).toEqual([
  359. Uint8Array.from([1, 2, 3]),
  360. Uint8Array.from([4, 5, 6]),
  361. Uint8Array.from([7, 8, 9]),
  362. ])
  363. expect(response.images.map((image) => image.mediaType)).toEqual(["image/png", "image/jpeg", "image/webp"])
  364. expect(response.images[0].providerMetadata).toMatchObject({ google: { thoughtSignature: "signature-1" } })
  365. expect(response.images[1].providerMetadata).toMatchObject({
  366. google: { candidateIndex: 0, partIndex: 3, finishReason: "STOP" },
  367. })
  368. expect(response.images[2].providerMetadata).toMatchObject({ google: { candidateIndex: 7, partIndex: 0 } })
  369. expect(response.usage?.inputTokens).toBe(5)
  370. expect(response.usage?.outputTokens).toBe(10)
  371. expect(response.usage?.reasoningTokens).toBe(3)
  372. expect(response.usage?.providerMetadata).toMatchObject({ google: { serviceTier: "STANDARD" } })
  373. expect(response.providerMetadata).toEqual({
  374. google: {
  375. modelVersion: "gemini-3.1-flash-image",
  376. responseId: "response-1",
  377. promptFeedback: undefined,
  378. candidates: [
  379. {
  380. index: 0,
  381. finishReason: "STOP",
  382. finishMessage: undefined,
  383. safetyRatings: [{ category: "safe" }],
  384. citationMetadata: undefined,
  385. groundingMetadata: undefined,
  386. parts: [
  387. {
  388. type: "inlineData",
  389. mediaType: "image/png",
  390. thought: undefined,
  391. thoughtSignature: "signature-1",
  392. },
  393. { type: "text", text: "planning", thought: true, thoughtSignature: "text-signature" },
  394. {
  395. type: "inlineData",
  396. mediaType: "image/png",
  397. thought: true,
  398. thoughtSignature: "draft-signature",
  399. },
  400. {
  401. type: "inlineData",
  402. mediaType: "image/jpeg",
  403. thought: undefined,
  404. thoughtSignature: undefined,
  405. },
  406. ],
  407. },
  408. {
  409. index: 7,
  410. finishReason: undefined,
  411. finishMessage: undefined,
  412. safetyRatings: undefined,
  413. citationMetadata: undefined,
  414. groundingMetadata: undefined,
  415. parts: [
  416. {
  417. type: "inlineData",
  418. mediaType: "image/webp",
  419. thought: undefined,
  420. thoughtSignature: undefined,
  421. },
  422. ],
  423. },
  424. ],
  425. },
  426. })
  427. }).pipe(
  428. Effect.provide(
  429. ImageClient.layer.pipe(
  430. Layer.provide(
  431. dynamicResponse((input) =>
  432. Effect.gen(function* () {
  433. const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
  434. expect(request.url).toBe(
  435. "https://generativelanguage.test/v1beta/models/any-model-id:generateContent?api=v1&trace=1",
  436. )
  437. expect(request.headers.get("x-goog-api-key")).toBe("test")
  438. expect(request.headers.get("x-default")).toBe("yes")
  439. expect(request.headers.get("x-request")).toBe("yes")
  440. expect(JSON.parse(input.text)).toEqual({
  441. contents: [{ role: "user", parts: [{ text: "A robot tending a rooftop garden" }] }],
  442. generationConfig: {
  443. responseModalities: ["IMAGE"],
  444. imageConfig: {
  445. aspectRatio: "3:2",
  446. imageSize: "2K",
  447. nativeImageOption: true,
  448. httpImageOption: true,
  449. },
  450. seed: 42,
  451. thinkingConfig: {
  452. thinkingLevel: "LOW",
  453. includeThoughts: false,
  454. nativeThinkingOption: true,
  455. httpThinkingOption: true,
  456. },
  457. futureOption: "http",
  458. httpOption: true,
  459. },
  460. labels: { deployment: "test" },
  461. safetySettings: [],
  462. })
  463. return input.respond(
  464. JSON.stringify({
  465. candidates: [
  466. {
  467. content: {
  468. parts: [
  469. {
  470. inlineData: { mimeType: "image/png", data: "AQID" },
  471. thoughtSignature: "signature-1",
  472. },
  473. { text: "planning", thought: true, thoughtSignature: "text-signature" },
  474. {
  475. inlineData: { mimeType: "image/png", data: "CgsM" },
  476. thought: true,
  477. thoughtSignature: "draft-signature",
  478. },
  479. { inlineData: { mimeType: "image/jpeg", data: "BAUG" } },
  480. ],
  481. },
  482. finishReason: "STOP",
  483. safetyRatings: [{ category: "safe" }],
  484. },
  485. {
  486. index: 7,
  487. content: { parts: [{ inlineData: { mimeType: "image/webp", data: "BwgJ" } }] },
  488. },
  489. ],
  490. usageMetadata: {
  491. promptTokenCount: 5,
  492. candidatesTokenCount: 7,
  493. thoughtsTokenCount: 3,
  494. totalTokenCount: 15,
  495. serviceTier: "STANDARD",
  496. },
  497. modelVersion: "gemini-3.1-flash-image",
  498. responseId: "response-1",
  499. }),
  500. { headers: { "content-type": "application/json" } },
  501. )
  502. }),
  503. ),
  504. ),
  505. ),
  506. ),
  507. ),
  508. )
  509. it.effect("includes Google diagnostics when no final image is returned", () =>
  510. Image.generate({
  511. model: Google.configure({ apiKey: "test", baseURL: "https://generativelanguage.test/v1beta" }).image(
  512. "gemini-3.1-flash-image",
  513. ),
  514. prompt: "A robot tending a rooftop garden",
  515. }).pipe(
  516. Effect.flip,
  517. Effect.tap((error) =>
  518. Effect.sync(() => {
  519. expect(error.reason._tag).toBe("InvalidProviderOutput")
  520. if (error.reason._tag !== "InvalidProviderOutput") return
  521. expect(error.reason.message).toContain("finish reasons: IMAGE_SAFETY")
  522. expect(error.reason.providerMetadata).toEqual({
  523. google: {
  524. promptFeedback: { blockReason: "SAFETY" },
  525. candidates: [
  526. {
  527. index: 0,
  528. finishReason: "IMAGE_SAFETY",
  529. finishMessage: "The generated image was blocked by safety filters.",
  530. safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
  531. citationMetadata: undefined,
  532. groundingMetadata: undefined,
  533. parts: [{ type: "text", text: "blocked", thought: false, thoughtSignature: undefined }],
  534. },
  535. ],
  536. },
  537. })
  538. }),
  539. ),
  540. Effect.provide(
  541. ImageClient.layer.pipe(
  542. Layer.provide(
  543. dynamicResponse((input) =>
  544. Effect.succeed(
  545. input.respond(
  546. JSON.stringify({
  547. candidates: [
  548. {
  549. content: { parts: [{ text: "blocked", thought: false }] },
  550. finishReason: "IMAGE_SAFETY",
  551. finishMessage: "The generated image was blocked by safety filters.",
  552. safetyRatings: [{ category: "HARM_CATEGORY_DANGEROUS_CONTENT", blocked: true }],
  553. },
  554. ],
  555. promptFeedback: { blockReason: "SAFETY" },
  556. }),
  557. { headers: { "content-type": "application/json" } },
  558. ),
  559. ),
  560. ),
  561. ),
  562. ),
  563. ),
  564. ),
  565. )
  566. })