From 07ce99dc9e0fda20a16f871d9470e7fcb6de28b9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 16:08:26 +0000 Subject: [PATCH] feat(ai): add Alibaba image generation --- packages/ai/src/protocols/alibaba-images.ts | 338 ++++++++++++++++++ packages/ai/src/protocols/index.ts | 1 + packages/ai/src/providers/alibaba.ts | 48 +++ packages/ai/src/providers/index.ts | 1 + .../generates-with-qwen-image-2-0.json | 28 ++ .../generates-with-wan-2-7.json | 28 ++ .../provider/alibaba-images.recorded.test.ts | 47 +++ .../ai/test/provider/alibaba-images.test.ts | 196 ++++++++++ 8 files changed, 687 insertions(+) create mode 100644 packages/ai/src/protocols/alibaba-images.ts create mode 100644 packages/ai/src/providers/alibaba.ts create mode 100644 packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json create mode 100644 packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json create mode 100644 packages/ai/test/provider/alibaba-images.recorded.test.ts create mode 100644 packages/ai/test/provider/alibaba-images.test.ts diff --git a/packages/ai/src/protocols/alibaba-images.ts b/packages/ai/src/protocols/alibaba-images.ts new file mode 100644 index 0000000000..45282a014b --- /dev/null +++ b/packages/ai/src/protocols/alibaba-images.ts @@ -0,0 +1,338 @@ +import { Effect, Schema } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { + GeneratedImage, + ImageModel, + ImageResponse, + type ImageModelDefaults, + type ImageRequest, + type ImageRoute, +} from "../image" +import { Auth, type Definition as AuthDefinition } from "../route/auth" +import { + InvalidProviderOutputReason, + LLMError, + UnknownProviderReason, + Usage, + mergeHttpOptions, + mergeJsonRecords, +} from "../schema" +import { ProviderShared } from "./shared" + +const ADAPTER = "alibaba-images" +export const DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/api/v1" +export const PATH = "/services/aigc/multimodal-generation/generation" + +export type Family = "qwen" | "wan" + +export interface QwenImageOptions extends Record { + readonly negativePrompt?: string + readonly promptExtend?: boolean + readonly watermark?: boolean +} + +export interface WanColor { + readonly hex: string + readonly ratio: string +} + +export interface WanImageOptions extends Record { + readonly resolution?: "1K" | "2K" | "4K" + readonly thinkingMode?: boolean + readonly colorPalette?: ReadonlyArray + readonly watermark?: boolean +} + +export interface AlibabaImageOptions extends Record { + readonly qwen?: QwenImageOptions + readonly wan?: WanImageOptions +} + +declare module "../image" { + interface ImageProviderOptions { + readonly alibaba?: AlibabaImageOptions + } +} + +const MessageInput = Schema.Struct({ + messages: Schema.Tuple([ + Schema.Struct({ + role: Schema.tag("user"), + content: Schema.Tuple([Schema.Struct({ text: Schema.String })]), + }), + ]), +}) + +const QwenBody = Schema.Struct({ + model: Schema.String, + input: MessageInput, + parameters: Schema.Struct({ + size: Schema.optional(Schema.String), + n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), + negative_prompt: Schema.optional(Schema.String), + prompt_extend: Schema.optional(Schema.Boolean), + watermark: Schema.optional(Schema.Boolean), + seed: Schema.optional(Schema.Int), + }), +}) +export type QwenBody = Schema.Schema.Type + +const WanBody = Schema.Struct({ + model: Schema.String, + input: MessageInput, + parameters: Schema.Struct({ + size: Schema.optional(Schema.String), + n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), + thinking_mode: Schema.optional(Schema.Boolean), + color_palette: Schema.optional( + Schema.Array( + Schema.Struct({ + hex: Schema.String, + ratio: Schema.String, + }), + ), + ), + watermark: Schema.optional(Schema.Boolean), + seed: Schema.optional(Schema.Int), + }), +}) +export type WanBody = Schema.Schema.Type + +const AlibabaResponse = Schema.Struct({ + output: Schema.optional( + Schema.Struct({ + choices: Schema.Array( + Schema.Struct({ + finish_reason: Schema.optional(Schema.String), + message: Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.Array( + Schema.Struct({ + image: Schema.String, + type: Schema.optional(Schema.String), + }), + ), + }), + }), + ), + finished: Schema.optional(Schema.Boolean), + }), + ), + usage: Schema.optional( + Schema.Struct({ + image_count: Schema.optional(Schema.Number), + input_tokens: Schema.optional(Schema.Number), + output_tokens: Schema.optional(Schema.Number), + total_tokens: Schema.optional(Schema.Number), + width: Schema.optional(Schema.Number), + height: Schema.optional(Schema.Number), + size: Schema.optional(Schema.String), + }), + ), + request_id: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}) + +export interface ModelInput { + readonly id: string + readonly family: Family + readonly auth: AuthDefinition + readonly baseURL?: string + readonly headers?: Record + readonly defaults?: ImageModelDefaults +} + +const options = (request: ImageRequest): AlibabaImageOptions => ({ + ...request.model.defaults?.providerOptions?.alibaba, + ...request.providerOptions?.alibaba, + qwen: { + ...(request.model.defaults?.providerOptions?.alibaba?.qwen as QwenImageOptions | undefined), + ...(request.providerOptions?.alibaba?.qwen as QwenImageOptions | undefined), + }, + wan: { + ...(request.model.defaults?.providerOptions?.alibaba?.wan as WanImageOptions | undefined), + ...(request.providerOptions?.alibaba?.wan as WanImageOptions | undefined), + }, +}) + +const messageInput = (request: ImageRequest) => ({ + messages: [{ role: "user" as const, content: [{ text: request.prompt }] }] as const, +}) + +const qwenBody = (request: ImageRequest): QwenBody => { + const qwen = options(request).qwen + return { + model: request.model.id, + input: messageInput(request), + parameters: { + size: request.size === undefined ? undefined : `${request.size.width}*${request.size.height}`, + n: request.count, + negative_prompt: qwen?.negativePrompt, + prompt_extend: qwen?.promptExtend, + watermark: qwen?.watermark, + seed: request.seed, + }, + } +} + +const wanBody = (request: ImageRequest): WanBody => { + const wan = options(request).wan + return { + model: request.model.id, + input: messageInput(request), + parameters: { + size: + wan?.resolution ?? (request.size === undefined ? undefined : `${request.size.width}*${request.size.height}`), + n: request.count, + thinking_mode: wan?.thinkingMode, + color_palette: wan?.colorPalette === undefined ? undefined : [...wan.colorPalette], + watermark: wan?.watermark, + seed: request.seed, + }, + } +} + +const invalidOutput = (message: string, metadata?: Record) => + new LLMError({ + module: ADAPTER, + method: "generate", + reason: new InvalidProviderOutputReason({ + message, + route: ADAPTER, + providerMetadata: metadata === undefined ? undefined : { alibaba: metadata }, + }), + }) + +const providerError = (code: string | undefined, message: string | undefined, requestID: string | undefined) => + new LLMError({ + module: ADAPTER, + method: "generate", + reason: new UnknownProviderReason({ + message: [code, message].filter(Boolean).join(": ") || "Alibaba image generation failed", + providerMetadata: { alibaba: { code, requestId: requestID } }, + }), + }) + +const applyQuery = (url: string, query: Record | undefined) => { + if (!query) return url + const next = new URL(url) + Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value)) + return next.toString() +} + +const PROTOCOL_BODY_FIELDS = new Set(["model", "input", "parameters"]) + +const bodyWithOverlay = Effect.fn("AlibabaImages.bodyWithOverlay")(function* ( + imageBody: QwenBody | WanBody, + overlay: Record | undefined, +) { + if (!overlay) return imageBody + const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key)) + if (reserved.length > 0) + return yield* ProviderShared.invalidRequest( + `http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`, + ) + return mergeJsonRecords(imageBody, overlay) ?? imageBody +}) + +const expiration = (url: string) => { + if (!URL.canParse(url)) return undefined + const value = new URL(url).searchParams.get("Expires") + if (value === null || !Number.isFinite(Number(value))) return undefined + return new Date(Number(value) * 1000).toISOString() +} + +export const model = (input: ModelInput) => { + const route: ImageRoute = { + id: `${ADAPTER}-${input.family}`, + generate: Effect.fn("AlibabaImages.generate")(function* (request: ImageRequest, execute) { + if (request.aspectRatio !== undefined) + return yield* ProviderShared.invalidRequest("Alibaba Images does not support the common aspectRatio option") + if ( + input.family === "qwen" && + (request.model.defaults?.providerOptions?.alibaba?.wan !== undefined || + request.providerOptions?.alibaba?.wan !== undefined) + ) + return yield* ProviderShared.invalidRequest("Qwen Image does not accept providerOptions.alibaba.wan") + if ( + input.family === "wan" && + (request.model.defaults?.providerOptions?.alibaba?.qwen !== undefined || + request.providerOptions?.alibaba?.qwen !== undefined) + ) + return yield* ProviderShared.invalidRequest("Wan Image does not accept providerOptions.alibaba.qwen") + if (input.family === "wan" && request.size !== undefined && options(request).wan?.resolution !== undefined) + return yield* ProviderShared.invalidRequest("Wan Image accepts either size or resolution, not both") + + const requestBody = yield* ProviderShared.validateWith( + Schema.decodeUnknownEffect(input.family === "qwen" ? QwenBody : WanBody), + )(input.family === "qwen" ? qwenBody(request) : wanBody(request)) + const http = mergeHttpOptions(request.model.defaults?.http, request.http) + const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body) + const text = ProviderShared.encodeJson(overlaidBody) + const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query) + const headers = yield* Auth.toEffect(input.auth)({ + request, + method: "POST", + url, + body: text, + headers: Headers.fromInput({ ...input.headers, ...http?.headers }), + }) + const response = yield* execute( + HttpClientRequest.post(url).pipe( + HttpClientRequest.setHeaders(headers), + HttpClientRequest.bodyText(text, "application/json"), + ), + ) + const payload = yield* response.json.pipe( + Effect.mapError(() => invalidOutput("Failed to read the Alibaba Images response")), + ) + const decoded = yield* Schema.decodeUnknownEffect(AlibabaResponse)(payload).pipe( + Effect.mapError(() => invalidOutput("Alibaba Images returned an invalid response")), + ) + if (decoded.code !== undefined || decoded.output === undefined) + return yield* providerError(decoded.code, decoded.message, decoded.request_id) + const urls = decoded.output.choices.flatMap((choice) => choice.message.content.map((content) => content.image)) + if (urls.length === 0) + return yield* invalidOutput("Alibaba Images returned no images", { requestId: decoded.request_id }) + + return new ImageResponse({ + images: urls.map( + (url) => + new GeneratedImage({ + mediaType: "image/png", + data: url, + providerMetadata: { + alibaba: { + modelId: request.model.id, + family: input.family, + expiresAt: expiration(url), + }, + }, + }), + ), + usage: + decoded.usage === undefined + ? undefined + : new Usage({ + inputTokens: decoded.usage.input_tokens, + outputTokens: decoded.usage.output_tokens, + totalTokens: decoded.usage.total_tokens, + providerMetadata: { alibaba: decoded.usage }, + }), + providerMetadata: { + alibaba: { + requestId: decoded.request_id, + modelId: request.model.id, + family: input.family, + }, + }, + }) + }), + } + return ImageModel.make({ id: input.id, provider: "alibaba", route, defaults: input.defaults }) +} + +export const AlibabaImages = { + model, +} as const diff --git a/packages/ai/src/protocols/index.ts b/packages/ai/src/protocols/index.ts index bd3ee8eeec..37f1c61005 100644 --- a/packages/ai/src/protocols/index.ts +++ b/packages/ai/src/protocols/index.ts @@ -1,4 +1,5 @@ export * as AnthropicMessages from "./anthropic-messages" +export * as AlibabaImages from "./alibaba-images" export * as BedrockConverse from "./bedrock-converse" export * as Gemini from "./gemini" export * as OpenAIChat from "./openai-chat" diff --git a/packages/ai/src/providers/alibaba.ts b/packages/ai/src/providers/alibaba.ts new file mode 100644 index 0000000000..b4cfddfb87 --- /dev/null +++ b/packages/ai/src/providers/alibaba.ts @@ -0,0 +1,48 @@ +import type { ImageModel } from "../image" +import { AlibabaImages, type AlibabaImageOptions, type Family } from "../protocols/alibaba-images" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { HttpOptions, ProviderID, mergeHttpOptions } from "../schema" + +export type { AlibabaImageOptions, QwenImageOptions, WanColor, WanImageOptions } from "../protocols/alibaba-images" + +export type AlibabaImageModelID = "qwen-image-2.0" | "qwen-image-2.0-pro" | "wan2.7-image" | "wan2.7-image-pro" + +export const id = ProviderID.make("alibaba") + +export type Config = ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly headers?: Record + readonly http?: HttpOptions.Input + readonly image?: { + readonly providerOptions?: AlibabaImageOptions + } +} + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "DASHSCOPE_API_KEY") + +const family = (modelID: AlibabaImageModelID): Family => (modelID.startsWith("qwen-") ? "qwen" : "wan") + +export const configure = (input: Config = {}) => { + const image = (modelID: AlibabaImageModelID): ImageModel => + AlibabaImages.model({ + id: modelID, + family: family(modelID), + auth: auth(input), + baseURL: input.baseURL, + headers: input.headers, + defaults: { + providerOptions: + input.image?.providerOptions === undefined ? undefined : { alibaba: { ...input.image.providerOptions } }, + http: mergeHttpOptions(input.http === undefined ? undefined : HttpOptions.make(input.http)), + }, + }) + + return { + id, + image, + configure, + } +} + +export const provider = configure() +export const image = provider.image diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 8b794f9a30..4c2139a6e0 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -1,6 +1,7 @@ export * as Anthropic from "./anthropic" export * as AnthropicCompatible from "./anthropic-compatible" export * as AmazonBedrock from "./amazon-bedrock" +export * as Alibaba from "./alibaba" export * as Azure from "./azure" export * as Cloudflare from "./cloudflare" export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare" diff --git a/packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json b/packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json new file mode 100644 index 0000000000..8c051c7532 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "tags": ["prefix:alibaba-images", "provider:alibaba", "protocol:alibaba-images"], + "name": "alibaba-images/generates-with-qwen-image-2-0", + "recordedAt": "2026-07-19T16:05:19.642Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"qwen-image-2.0\",\"input\":{\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"A simple flat black circle centered on a plain white background.\"}]}]},\"parameters\":{\"size\":\"512*512\",\"prompt_extend\":false,\"watermark\":false}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"output\":{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"image\":\"https://dashscope-463f.oss-accelerate.aliyuncs.com/7d/7b/20260720/991d10e3/fe425a64-dc6f-4c67-a4fa-a06810984ba9.png?Expires=1785082919&OSSAccessKeyId=fixture&Signature=fixture\"}],\"role\":\"assistant\"}}]},\"usage\":{\"height\":512,\"image_count\":1,\"width\":512},\"request_id\":\"484490f4-9881-90eb-8d52-d270d2395920\"}" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json b/packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json new file mode 100644 index 0000000000..373482ec13 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "tags": ["prefix:alibaba-images", "provider:alibaba", "protocol:alibaba-images"], + "name": "alibaba-images/generates-with-wan-2-7", + "recordedAt": "2026-07-19T16:05:23.617Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://dashscope-intl.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"wan2.7-image\",\"input\":{\"messages\":[{\"role\":\"user\",\"content\":[{\"text\":\"A simple flat black square centered on a plain white background.\"}]}]},\"parameters\":{\"size\":\"1K\",\"thinking_mode\":false,\"watermark\":false}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json" + }, + "body": "{\"output\":{\"choices\":[{\"finish_reason\":\"stop\",\"message\":{\"content\":[{\"image\":\"https://dashscope-463f.oss-accelerate.aliyuncs.com/1d/ae/20260720/10739b93/1784477122_595381b5.png?Expires=1784563522&OSSAccessKeyId=fixture&Signature=fixture\",\"type\":\"image\"}],\"role\":\"assistant\"}}],\"finished\":true},\"usage\":{\"image_count\":1,\"input_tokens\":31,\"output_tokens\":2,\"size\":\"1024*1024\",\"total_tokens\":33},\"request_id\":\"ec5cacb5-c7b0-9e46-8459-49003a3816c3\"}" + } + } + ] +} diff --git a/packages/ai/test/provider/alibaba-images.recorded.test.ts b/packages/ai/test/provider/alibaba-images.recorded.test.ts new file mode 100644 index 0000000000..4980cd81b1 --- /dev/null +++ b/packages/ai/test/provider/alibaba-images.recorded.test.ts @@ -0,0 +1,47 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Image } from "../../src" +import { Alibaba } from "../../src/providers" +import { recordedTests } from "../recorded-test" + +const alibaba = Alibaba.configure({ + apiKey: process.env.DASHSCOPE_API_KEY ?? "fixture", +}) + +const recorded = recordedTests({ + prefix: "alibaba-images", + provider: "alibaba", + protocol: "alibaba-images", + requires: ["DASHSCOPE_API_KEY"], +}) + +describe("Alibaba Images recorded", () => { + recorded.effect("generates with Qwen Image 2.0", () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model: alibaba.image("qwen-image-2.0"), + prompt: "A simple flat black circle centered on a plain white background.", + size: { width: 512, height: 512 }, + providerOptions: { alibaba: { qwen: { promptExtend: false, watermark: false } } }, + }) + + expect(response.images).toHaveLength(1) + expect(response.image?.mediaType).toBe("image/png") + expect(response.image?.data).toStartWith("https://") + }), + ) + + recorded.effect("generates with Wan 2.7", () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model: alibaba.image("wan2.7-image"), + prompt: "A simple flat black square centered on a plain white background.", + providerOptions: { alibaba: { wan: { resolution: "1K", thinkingMode: false, watermark: false } } }, + }) + + expect(response.images).toHaveLength(1) + expect(response.image?.mediaType).toBe("image/png") + expect(response.image?.data).toStartWith("https://") + }), + ) +}) diff --git a/packages/ai/test/provider/alibaba-images.test.ts b/packages/ai/test/provider/alibaba-images.test.ts new file mode 100644 index 0000000000..d8bfd12b93 --- /dev/null +++ b/packages/ai/test/provider/alibaba-images.test.ts @@ -0,0 +1,196 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { Image, ImageClient } from "../../src" +import { Alibaba } from "../../src/providers" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" + +const response = (family: "qwen" | "wan") => ({ + output: { + choices: [ + { + finish_reason: "stop", + message: { + role: "assistant", + content: [ + { + image: "https://dashscope-result-intl.oss-cn-singapore.aliyuncs.com/result.png?Expires=1893456000", + ...(family === "wan" ? { type: "image" } : {}), + }, + ], + }, + }, + ], + ...(family === "wan" ? { finished: true } : {}), + }, + usage: + family === "wan" + ? { image_count: 1, input_tokens: 10, output_tokens: 2, total_tokens: 12, size: "2048*2048" } + : { image_count: 1, width: 1024, height: 768 }, + request_id: `request-${family}`, +}) + +describe("Alibaba Images", () => { + it.effect("generates Qwen Image 2.0 through the international synchronous route", () => + Effect.gen(function* () { + const result = yield* Image.generate({ + model: Alibaba.configure({ + apiKey: "test", + baseURL: "https://dashscope-intl.test/api/v1", + image: { providerOptions: { qwen: { promptExtend: true, watermark: false } } }, + http: { headers: { "x-default": "yes" } }, + }).image("qwen-image-2.0-pro"), + prompt: "A robot tending a rooftop garden", + count: 2, + size: { width: 1024, height: 768 }, + seed: 42, + providerOptions: { alibaba: { qwen: { negativePrompt: "blurry" } } }, + http: { headers: { "x-request": "yes" }, query: { trace: "1" }, body: { metadata: "test" } }, + }) + + expect(result.image?.data).toContain("dashscope-result-intl") + expect(result.image?.providerMetadata).toEqual({ + alibaba: { + modelId: "qwen-image-2.0-pro", + family: "qwen", + expiresAt: "2030-01-01T00:00:00.000Z", + }, + }) + expect(result.providerMetadata).toEqual({ + alibaba: { requestId: "request-qwen", modelId: "qwen-image-2.0-pro", family: "qwen" }, + }) + }).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://dashscope-intl.test/api/v1/services/aigc/multimodal-generation/generation?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: "qwen-image-2.0-pro", + input: { + messages: [{ role: "user", content: [{ text: "A robot tending a rooftop garden" }] }], + }, + parameters: { + size: "1024*768", + n: 2, + negative_prompt: "blurry", + prompt_extend: true, + watermark: false, + seed: 42, + }, + metadata: "test", + }) + return input.respond(JSON.stringify(response("qwen")), { + headers: { "content-type": "application/json" }, + }) + }), + ), + ), + ), + ), + ), + ) + + it.effect("generates Wan 2.7 through the international synchronous route", () => + Effect.gen(function* () { + const result = yield* Image.generate({ + model: Alibaba.configure({ apiKey: "test", baseURL: "https://dashscope-intl.test/api/v1" }).image( + "wan2.7-image-pro", + ), + prompt: "A flower shop with a wooden door", + count: 1, + seed: 7, + providerOptions: { + alibaba: { + wan: { + resolution: "2K", + thinkingMode: true, + watermark: false, + colorPalette: [ + { hex: "#112233", ratio: "60.00%" }, + { hex: "#445566", ratio: "25.00%" }, + { hex: "#778899", ratio: "15.00%" }, + ], + }, + }, + }, + }) + + expect(result.images).toHaveLength(1) + expect(result.image?.mediaType).toBe("image/png") + expect(result.usage?.totalTokens).toBe(12) + expect(result.usage?.providerMetadata).toEqual({ alibaba: response("wan").usage }) + }).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://dashscope-intl.test/api/v1/services/aigc/multimodal-generation/generation", + ) + expect(JSON.parse(input.text)).toEqual({ + model: "wan2.7-image-pro", + input: { messages: [{ role: "user", content: [{ text: "A flower shop with a wooden door" }] }] }, + parameters: { + size: "2K", + n: 1, + thinking_mode: true, + color_palette: [ + { hex: "#112233", ratio: "60.00%" }, + { hex: "#445566", ratio: "25.00%" }, + { hex: "#778899", ratio: "15.00%" }, + ], + watermark: false, + seed: 7, + }, + }) + return input.respond(JSON.stringify(response("wan")), { + headers: { "content-type": "application/json" }, + }) + }), + ), + ), + ), + ), + ), + ) + + it.effect("surfaces Alibaba error envelopes as typed provider errors", () => + Image.generate({ + model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"), + prompt: "A robot", + }).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error.reason._tag).toBe("UnknownProvider") + expect(error.message).toContain("InvalidParameter: invalid prompt") + }), + ), + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => + Effect.succeed( + input.respond( + JSON.stringify({ request_id: "request-error", code: "InvalidParameter", message: "invalid prompt" }), + { headers: { "content-type": "application/json" } }, + ), + ), + ), + ), + ), + ), + ), + ) +})