فهرست منبع

feat(ai): align Alibaba image API

Aiden Cline 2 هفته پیش
والد
کامیت
bee140e63b

+ 24 - 0
packages/ai/README.md

@@ -152,6 +152,30 @@ future string values and arbitrary native Gemini `generationConfig` fields remai
 their mapped aliases, and `http.body` is the final deep overlay. The selected model ID is sent to Gemini
 `generateContent` without a local allowlist.
 
+Alibaba's international synchronous API uses the same request shape for Qwen and Wan image models:
+
+```ts
+import { Alibaba } from "@opencode-ai/ai/providers"
+
+const response =
+  yield *
+  Image.generate({
+    model: Alibaba.configure({ apiKey }).image("any-model-id"),
+    prompt: "Turn this source into a bright orange sun icon",
+    images: [ImageInput.bytes(sourceBytes, "image/jpeg")],
+    options: {
+      resolution: "2K",
+      promptExtend: false,
+      future_parameter: true,
+    },
+    http,
+  })
+```
+
+Known Qwen and Wan aliases autocomplete in one open request option object. Unknown native parameter keys pass
+through, native keys override aliases, and `http.body.parameters` is the final deep overlay. Model IDs are not
+locally allowlisted. Alibaba returns temporary signed URLs; persist each image before its optional `expiresAt`.
+
 Z.ai image models infer open Z.ai-native options from the selected model:
 
 ```ts

+ 1 - 0
packages/ai/src/image.ts

@@ -118,6 +118,7 @@ export type ImageRequestInput<Model extends object = ImageModel> = Omit<
 export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
   mediaType: Schema.String,
   data: Schema.Union([Schema.String, Schema.Uint8Array]),
+  expiresAt: Schema.optional(Schema.String),
   providerMetadata: Schema.optional(ProviderMetadata),
 }) {}
 

+ 75 - 186
packages/ai/src/protocols/alibaba-images.ts

@@ -1,13 +1,6 @@
 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 { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image"
 import { Auth, type Definition as AuthDefinition } from "../route/auth"
 import {
   InvalidProviderOutputReason,
@@ -16,88 +9,45 @@ import {
   Usage,
   mergeHttpOptions,
   mergeJsonRecords,
+  type HttpOptions,
 } from "../schema"
 import { ProviderShared } from "./shared"
+import { ImageInputs } from "./utils/image-input"
 
 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 {
-  readonly negativePrompt?: string
-  readonly promptExtend?: boolean
-  readonly watermark?: boolean
-}
+export type AlibabaImageString<Known extends string> = Known | (string & {})
 
-export interface WanColor {
+export interface AlibabaColor {
   readonly hex: string
   readonly ratio: string
 }
 
-export interface WanImageOptions {
-  readonly resolution?: "1K" | "2K" | "4K"
+export type AlibabaImageOptions = {
+  readonly n?: number
+  readonly size?: string
+  readonly resolution?: AlibabaImageString<"1K" | "2K" | "4K">
+  readonly negativePrompt?: string
+  readonly promptExtend?: boolean
   readonly thinkingMode?: boolean
-  readonly colorPalette?: ReadonlyArray<WanColor>
+  readonly colorPalette?: ReadonlyArray<AlibabaColor>
   readonly watermark?: boolean
-}
-
-export interface AlibabaImageOptions {
-  readonly qwen?: QwenImageOptions
-  readonly wan?: WanImageOptions
-}
-
-declare module "../image" {
-  interface ImageProviderOptions {
-    readonly alibaba?: AlibabaImageOptions
+  readonly seed?: number
+} & Record<string, unknown>
+
+export type AlibabaImageBody = Record<string, unknown> & {
+  readonly model: string
+  readonly input: {
+    readonly messages: ReadonlyArray<{
+      readonly role: "user"
+      readonly content: ReadonlyArray<{ readonly image: string } | { readonly text: string }>
+    }>
   }
+  readonly parameters: Record<string, unknown>
 }
 
-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<typeof QwenBody>
-
-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<typeof WanBody>
-
 const AlibabaResponse = Schema.Struct({
   output: Schema.optional(
     Schema.Struct({
@@ -136,60 +86,22 @@ const AlibabaResponse = Schema.Struct({
 
 export interface ModelInput {
   readonly id: string
-  readonly family: Family
   readonly auth: AuthDefinition
   readonly baseURL?: string
   readonly headers?: Record<string, string>
-  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,
-    },
-  }
+  readonly http?: HttpOptions
 }
 
-const wanBody = (request: ImageRequest): WanBody => {
-  const wan = options(request).wan
+const nativeOptions = (options: AlibabaImageOptions | undefined) => {
+  if (!options) return undefined
+  const { resolution, negativePrompt, promptExtend, thinkingMode, colorPalette, ...native } = options
   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,
-    },
+    size: resolution,
+    negative_prompt: negativePrompt,
+    prompt_extend: promptExtend,
+    thinking_mode: thinkingMode,
+    color_palette: colorPalette,
+    ...native,
   }
 }
 
@@ -221,21 +133,6 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
   return next.toString()
 }
 
-const PROTOCOL_BODY_FIELDS = new Set(["model", "input", "parameters"])
-
-const bodyWithOverlay = Effect.fn("AlibabaImages.bodyWithOverlay")(function* (
-  imageBody: QwenBody | WanBody,
-  overlay: Record<string, unknown> | 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")
@@ -245,32 +142,31 @@ const expiration = (url: string) => {
 }
 
 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 route: ImageRoute<AlibabaImageOptions> = {
+    id: ADAPTER,
+    generate: Effect.fn("AlibabaImages.generate")(function* (request: ImageRequestFor<AlibabaImageOptions>, execute) {
+      const content = yield* Effect.forEach(request.images ?? [], (image) => {
+        if (image.type === "bytes") return Effect.succeed({ image: ImageInputs.dataUrl(image) })
+        if (image.type === "url") return Effect.succeed({ image: image.url })
+        return ImageInputs.invalid(ADAPTER, "Alibaba Images accepts image URLs, data URLs, and bytes")
+      })
+      const requestBody: AlibabaImageBody = {
+        model: request.model.id,
+        input: { messages: [{ role: "user", content: [...content, { text: request.prompt }] }] },
+        parameters: nativeOptions(request.options) ?? {},
+      }
+      const http = mergeHttpOptions(request.model.http, request.http)
+      const overlay = mergeJsonRecords(requestBody, http?.body) ?? requestBody
+      const body: AlibabaImageBody = {
+        ...overlay,
+        model: requestBody.model,
+        input: requestBody.input,
+        parameters:
+          overlay.parameters !== null && typeof overlay.parameters === "object" && !Array.isArray(overlay.parameters)
+            ? (overlay.parameters as Record<string, unknown>)
+            : requestBody.parameters,
+      }
+      const text = ProviderShared.encodeJson(body)
       const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
       const headers = yield* Auth.toEffect(input.auth)({
         request,
@@ -293,26 +189,20 @@ export const model = (input: ModelInput) => {
       )
       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))
+      const urls = decoded.output.choices.flatMap((choice) => choice.message.content.map((item) => item.image))
       if (urls.length === 0)
         return yield* invalidOutput("Alibaba Images returned no images", { requestId: decoded.request_id })
 
       return new ImageResponse({
-        images: urls.map((url) => {
-          const expiresAt = expiration(url)
-          return new GeneratedImage({
-            mediaType: "image/png",
-            data: url,
-            expiresAt,
-            providerMetadata: {
-              alibaba: {
-                modelId: request.model.id,
-                family: input.family,
-                expiresAt,
-              },
-            },
-          })
-        }),
+        images: urls.map(
+          (url) =>
+            new GeneratedImage({
+              mediaType: "image/png",
+              data: url,
+              expiresAt: expiration(url),
+              providerMetadata: { alibaba: { modelId: request.model.id } },
+            }),
+        ),
         usage:
           decoded.usage === undefined
             ? undefined
@@ -322,17 +212,16 @@ export const model = (input: ModelInput) => {
                 totalTokens: decoded.usage.total_tokens,
                 providerMetadata: { alibaba: decoded.usage },
               }),
-        providerMetadata: {
-          alibaba: {
-            requestId: decoded.request_id,
-            modelId: request.model.id,
-            family: input.family,
-          },
-        },
+        providerMetadata: { alibaba: { requestId: decoded.request_id, modelId: request.model.id } },
       })
     }),
   }
-  return ImageModel.make({ id: input.id, provider: "alibaba", route, defaults: input.defaults })
+  return ImageModel.make<AlibabaImageOptions>({
+    id: input.id,
+    provider: "alibaba",
+    route,
+    http: input.http,
+  })
 }
 
 export const AlibabaImages = {

+ 5 - 18
packages/ai/src/providers/alibaba.ts

@@ -1,11 +1,8 @@
-import type { ImageModel } from "../image"
-import { AlibabaImages, type AlibabaImageOptions, type Family } from "../protocols/alibaba-images"
+import { AlibabaImages } from "../protocols/alibaba-images"
 import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
-import { HttpOptions, ProviderID, mergeHttpOptions } from "../schema"
+import { HttpOptions, ProviderID } 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 type { AlibabaColor, AlibabaImageOptions } from "../protocols/alibaba-images"
 
 export const id = ProviderID.make("alibaba")
 
@@ -13,28 +10,18 @@ export type Config = ProviderAuthOption<"optional"> & {
   readonly baseURL?: string
   readonly headers?: Record<string, string>
   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 =>
+  const image = (modelID: string) =>
     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)),
-      },
+      http: input.http === undefined ? undefined : HttpOptions.make(input.http),
     })
 
   return {

+ 3 - 5
packages/ai/test/auth-options.types.ts

@@ -252,8 +252,6 @@ GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key"
 // @ts-expect-error GitHub Copilot model selectors only accept model ids.
 GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1", {})
 
-Alibaba.configure({ image: { providerOptions: { qwen: { promptExtend: true } } } }).image("qwen-image-2.0")
-// @ts-expect-error Alibaba Qwen options are closed and reject unimplemented parameters.
-Alibaba.configure({ image: { providerOptions: { qwen: { style: "photorealistic" } } } })
-// @ts-expect-error Alibaba Wan options are closed and reject unimplemented parameters.
-Alibaba.configure({ image: { providerOptions: { wan: { parameters: { watermark: true } } } } })
+Alibaba.configure({ apiKey: "dashscope-key" }).image("any-model-id")
+// @ts-expect-error Alibaba image options are request-scoped, not provider configuration.
+Alibaba.configure({ image: { options: { promptExtend: true } } })

+ 10 - 1
packages/ai/test/exports.test.ts

@@ -3,6 +3,7 @@ import { ImageInput, LLM, LLMClient, Provider } from "@opencode-ai/ai"
 import { Route, Protocol } from "@opencode-ai/ai/route"
 import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
 import {
+  Alibaba,
   CloudflareAIGateway,
   CloudflareWorkersAI,
   OpenAI,
@@ -11,7 +12,13 @@ import {
   XAI,
 } from "@opencode-ai/ai/providers"
 import * as GitHubCopilot from "@opencode-ai/ai/providers/github-copilot"
-import { OpenAIChat, OpenAICompatibleChat, OpenAICompatibleResponses, OpenAIResponses } from "@opencode-ai/ai/protocols"
+import {
+  AlibabaImages,
+  OpenAIChat,
+  OpenAICompatibleChat,
+  OpenAICompatibleResponses,
+  OpenAIResponses,
+} from "@opencode-ai/ai/protocols"
 import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
 
 describe("public exports", () => {
@@ -32,6 +39,7 @@ describe("public exports", () => {
   test("provider barrels expose user-facing facades", async () => {
     const { OpenAICompatibleResponses } = await import("@opencode-ai/ai/providers")
 
+    expect(Alibaba.configure({ apiKey: "fixture" }).image("any-model-id").provider).toBe("alibaba")
     expect(OpenAI.model).toBeFunction()
     expect(OpenAI.provider.responses).toBe(OpenAI.responses)
     expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
@@ -72,6 +80,7 @@ describe("public exports", () => {
   })
 
   test("protocol barrels expose supported low-level routes", () => {
+    expect(AlibabaImages.DEFAULT_BASE_URL).toBe("https://dashscope-intl.aliyuncs.com/api/v1")
     expect(OpenAIChat.route.id).toBe("openai-chat")
     expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
     expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")

BIN
packages/ai/test/fixtures/images/edit-source-256.jpg


+ 32 - 0
packages/ai/test/fixtures/recordings/alibaba-images/edits-with-qwen-image-2-0.json

@@ -0,0 +1,32 @@
+{
+  "version": 1,
+  "metadata": {
+    "tags": [
+      "prefix:alibaba-images",
+      "provider:alibaba",
+      "protocol:alibaba-images"
+    ],
+    "name": "alibaba-images/edits-with-qwen-image-2-0",
+    "recordedAt": "2026-07-22T05:30:23.625Z"
+  },
+  "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\":[{\"image\":\"data:image/jpeg;base64,/9j/4AAQSkZJRgABAgAAAQABAAD//gAQTGF2YzYwLjMxLjEwMgD/2wBDAAgQEBMQExYWFhYWFhoYGhsbGxoaGhobGxsdHR0iIiIdHR0bGx0dICAiIiUmJSMjIiMmJigoKDAwLi44ODpFRVP/xABrAAEBAQEBAQAAAAAAAAAAAAAABwgGBAUBAQAAAAAAAAAAAAAAAAAAAAAQAAICAQEFBwMEAwEAAAAAAAABAwIEEQYFEiFhwXGBUUExE6GRU0LwciLh8bEjEQEAAAAAAAAAAAAAAAAAAAAA/8AAEQgAgACAAwEiAAIRAAMRAP/aAAwDAQACEQMRAD8Av4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB458iLFjtLNesdK+9rPRf76e4yMiPFivNLbhpHV2s+i7X6LzMhb33xPvabitrWKrfxxa8qrzfnZ+r8EBTc/bPm64USa/LLrz61omvq/AnMu0W9ZXq8q9elFSi+ldTigB2se0W9Ynqsq9ul1S6+tSiYG2b1Vc2Jafli15dXRt/R+BBgBuvHyIcqOssN6yUt7Wq9V/h9HzPYY03RvefdM3HTW0dn/AOkWvKy815WXo/ua+x8iPKhpNFbipJVWq/36r2a8wPaAAAAAAAAAAIDtnnvWLCq+WnyydfSlX9X9iDHZ7QyuXeuW3+m6ou6lUjjAAAAAAAXbYzeDVpcKz5NfLH0ftdLv5P7kJOw2flcW9cRr1k4H3XTr2gbHAAAAAAAAAAGO9oYnFvXLT/VdXXdeqZxhdts8Bq0WZVcmvik6NaujffzX2ISAAAAAADr9wRuXeuGl6ScfhRO3YcgXHYzAdpZcyy/rRfFH1s+dmu5aLxA0MAAAAAAAAAAPDk40WXDJDLXipJXha7V1Xuupj7eu6p91TuORO1Hr8cmnK67LL1Rs8+fk4sOZFaKeOslLe6f/AFP3T8muYGGAXHP2Nkq3bDlV6/jl5WXRXXJ+KROpdxb0ielsOZ/wXGvvRsDkwdXHuLekr0WHMv5V4F97tFDwNjZrtWzJVHX8cb4rvo7acK8NQJtuvdc+9Z1FGtKrT5JNP60r2vyXqbBxMWLCgjgiXDSNaLzfm31b5sYuJBhRKKCOsdF6L1fm37t9WfRAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/9k=\"},{\"text\":\"Turn the black source shape into a bright orange sun icon on a pale blue background.\"}]}]},\"parameters\":{\"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/89/20260722/74716928/0e082b32-9ff8-4e81-aa78-bde6b41d33ae.png?Expires=1785304023&OSSAccessKeyId=%5BREDACTED%5D&Signature=%5BREDACTED%5D\"}],\"role\":\"assistant\"}}]},\"usage\":{\"height\":1024,\"image_count\":1,\"width\":1024},\"request_id\":\"13d119d7-dbf1-98a7-9fae-d4ad6441f8a7\"}"
+      }
+    }
+  ]
+}

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 20 - 0
packages/ai/test/fixtures/recordings/alibaba-images/edits-with-wan-2-7.json


+ 21 - 1
packages/ai/test/image.types.ts

@@ -7,7 +7,7 @@ import {
   type ImageRequestFor,
   type ImageRoute,
 } from "../src"
-import { Google, OpenAI, XAI, ZAI } from "../src/providers"
+import { Alibaba, Google, OpenAI, XAI, ZAI } from "../src/providers"
 
 type GoogleLikeOptions = {
   readonly aspectRatio?: "1:1" | "16:9"
@@ -58,6 +58,26 @@ Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { seed:
 // @ts-expect-error Known Google boolean options retain their value kind.
 Image.generate({ model: googleProvider, prompt: "A lighthouse", options: { includeThoughts: "yes" } })
 
+const alibaba = Alibaba.configure({ apiKey: "test" }).image("any-model-id")
+Image.generate({
+  model: alibaba,
+  prompt: "A lighthouse",
+  images: [ImageInput.url("https://example.com/source.png")],
+  options: {
+    resolution: "2K",
+    negativePrompt: "fog",
+    promptExtend: true,
+    thinkingMode: false,
+    colorPalette: [{ hex: "#112233", ratio: "100.00%" }],
+    watermark: false,
+    future_parameter: true,
+  },
+})
+// @ts-expect-error Known Alibaba boolean options retain their value kind.
+Image.generate({ model: alibaba, prompt: "A lighthouse", options: { promptExtend: "yes" } })
+// @ts-expect-error Known Alibaba numeric options retain their value kind.
+Image.generate({ model: alibaba, prompt: "A lighthouse", options: { seed: "42" } })
+
 const openai = OpenAI.image("gpt-image-2")
 // @ts-expect-error Image generation options are request-scoped, not provider configuration.
 OpenAI.configure({ image: { options: { quality: "medium" } } })

+ 55 - 4
packages/ai/test/provider/alibaba-images.recorded.test.ts

@@ -1,7 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import { Effect, Option, Schema } from "effect"
-import { Image } from "../../src"
+import { Image, ImageInput } from "../../src"
 import { Alibaba } from "../../src/providers"
+import { dimensions } from "../lib/image"
 import { recordedTests } from "../recorded-test"
 
 const alibaba = Alibaba.configure({
@@ -58,6 +59,15 @@ const recorded = recordedTests({
   options: { redact: { body: redactSignedUrls } },
 })
 
+const inspectLive = async (value: string | Uint8Array | undefined) => {
+  if (process.env.RECORD !== "true" || typeof value !== "string") return
+  const response = await fetch(value)
+  const bytes = new Uint8Array(await response.arrayBuffer())
+  expect(response.headers.get("content-type")).toMatch(/^image\//)
+  expect(dimensions(bytes).width).toBeGreaterThan(0)
+  expect(dimensions(bytes).height).toBeGreaterThan(0)
+}
+
 test("recorder redacts signed URL credentials in nested JSON strings repeatably", () => {
   const body = JSON.stringify({
     output: {
@@ -87,8 +97,7 @@ describe("Alibaba Images recorded", () => {
       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 } } },
+        options: { size: "512*512", promptExtend: false, watermark: false },
       })
 
       expect(response.images).toHaveLength(1)
@@ -102,12 +111,54 @@ describe("Alibaba Images recorded", () => {
       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 } } },
+        options: { resolution: "1K", thinkingMode: false, watermark: false },
+      })
+
+      expect(response.images).toHaveLength(1)
+      expect(response.image?.mediaType).toBe("image/png")
+      expect(response.image?.data).toStartWith("https://")
+    }),
+  )
+
+  recorded.effect("edits with Qwen Image 2.0", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model: alibaba.image("qwen-image-2.0"),
+        prompt: "Turn the black source shape into a bright orange sun icon on a pale blue background.",
+        images: [
+          ImageInput.bytes(
+            yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source.jpg").bytes()),
+            "image/jpeg",
+          ),
+        ],
+        options: { promptExtend: false, watermark: false },
+      })
+
+      expect(response.images).toHaveLength(1)
+      expect(response.image?.mediaType).toBe("image/png")
+      expect(response.image?.data).toStartWith("https://")
+      yield* Effect.promise(() => inspectLive(response.image?.data))
+    }),
+  )
+
+  recorded.effect("edits with Wan 2.7", () =>
+    Effect.gen(function* () {
+      const response = yield* Image.generate({
+        model: alibaba.image("wan2.7-image"),
+        prompt: "Turn the black source shape into a bright orange sun icon on a pale blue background.",
+        images: [
+          ImageInput.bytes(
+            yield* Effect.promise(() => Bun.file("test/fixtures/images/edit-source-256.jpg").bytes()),
+            "image/jpeg",
+          ),
+        ],
+        options: { resolution: "1K", thinkingMode: false, watermark: false },
       })
 
       expect(response.images).toHaveLength(1)
       expect(response.image?.mediaType).toBe("image/png")
       expect(response.image?.data).toStartWith("https://")
+      yield* Effect.promise(() => inspectLive(response.image?.data))
     }),
   )
 })

+ 63 - 201
packages/ai/test/provider/alibaba-images.test.ts

@@ -1,247 +1,109 @@
 import { describe, expect } from "bun:test"
 import { Effect, Layer } from "effect"
-import { HttpClientRequest } from "effect/unstable/http"
-import { Image, ImageClient } from "../../src"
+import { Image, ImageClient, ImageInput } 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}`,
+const payload = (url = "https://example.com/result.png?Expires=1893456000") => ({
+  output: { choices: [{ message: { content: [{ image: url }] } }] },
+  usage: { total_tokens: 12 },
+  request_id: "request-alibaba",
 })
 
-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?.expiresAt).toBe("2030-01-01T00:00:00.000Z")
-      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" },
-                })
-              }),
-            ),
-          ),
-        ),
-      ),
+const layer = (inspect: (body: Record<string, unknown>) => void, url?: string) =>
+  ImageClient.layer.pipe(
+    Layer.provide(
+      dynamicResponse((input) => {
+        inspect(JSON.parse(input.text))
+        return Effect.succeed(
+          input.respond(JSON.stringify(payload(url)), { headers: { "content-type": "application/json" } }),
+        )
+      }),
     ),
   )
 
-  it.effect("generates Wan 2.7 through the international synchronous route", () =>
+describe("Alibaba Images", () => {
+  it.effect("supports open options and arbitrary models", () =>
     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%" },
-              ],
-            },
+        model: Alibaba.configure({ apiKey: "test" }).image("future-model-id"),
+        prompt: "A robot",
+        options: {
+          resolution: "2K",
+          negativePrompt: "blurry",
+          negative_prompt: "native wins",
+          future_parameter: true,
+        },
+        http: {
+          body: {
+            model: "ignored-model",
+            input: { messages: [] },
+            parameters: { seed: 7 },
           },
         },
       })
-
-      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 })
+      expect(result.image?.expiresAt).toBe("2030-01-01T00:00:00.000Z")
     }).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" },
-                })
-              }),
-            ),
-          ),
-        ),
+        layer((body) => {
+          expect(body.model).toBe("future-model-id")
+          expect(body.input).toEqual({ messages: [{ role: "user", content: [{ text: "A robot" }] }] })
+          expect(body.parameters).toEqual({
+            size: "2K",
+            negative_prompt: "native wins",
+            future_parameter: true,
+            seed: 7,
+          })
+        }),
       ),
     ),
   )
 
-  it.effect("surfaces Alibaba error envelopes as typed provider errors", () =>
+  it.effect("lowers ordered image inputs before text", () =>
     Image.generate({
       model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"),
-      prompt: "A robot",
+      prompt: "Combine these",
+      images: [
+        ImageInput.url("https://example.com/first.png"),
+        ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"),
+      ],
     }).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" } },
-                ),
-              ),
-            ),
-          ),
-        ),
+        layer((body) => {
+          const input = body.input as { messages: Array<{ content: unknown }> }
+          expect(input.messages[0].content).toEqual([
+            { image: "https://example.com/first.png" },
+            { image: "data:image/png;base64,AQID" },
+            { text: "Combine these" },
+          ])
+        }),
       ),
     ),
   )
 
-  it.effect("rejects parameters overlays owned by the protocol", () =>
+  it.effect("rejects provider file inputs", () =>
     Image.generate({
-      model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"),
+      model: Alibaba.configure({ apiKey: "test" }).image("any-model"),
       prompt: "A robot",
-      http: { body: { parameters: { watermark: true } } },
+      images: [ImageInput.file("file_123")],
     }).pipe(
       Effect.flip,
-      Effect.tap((error) =>
-        Effect.sync(() => {
-          expect(error.reason._tag).toBe("InvalidRequest")
-          expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): parameters")
-        }),
-      ),
+      Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))),
       Effect.provide(
-        ImageClient.layer.pipe(
-          Layer.provide(
-            dynamicResponse(() => Effect.die("reserved body validation must happen before the request is sent")),
-          ),
-        ),
+        ImageClient.layer.pipe(Layer.provide(dynamicResponse(() => Effect.die("must fail before network I/O")))),
       ),
     ),
   )
 
-  it.effect("ignores finite expiration values outside the Date range", () =>
+  it.effect("ignores expiration values outside the Date range", () =>
     Effect.gen(function* () {
       const result = yield* Image.generate({
-        model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"),
+        model: Alibaba.configure({ apiKey: "test" }).image("any-model"),
         prompt: "A robot",
       })
-
       expect(result.image?.expiresAt).toBeUndefined()
-      expect(result.image?.providerMetadata?.alibaba?.expiresAt).toBeUndefined()
-    }).pipe(
-      Effect.provide(
-        ImageClient.layer.pipe(
-          Layer.provide(
-            dynamicResponse((input) => {
-              const payload = response("qwen")
-              payload.output.choices[0].message.content[0].image =
-                "https://dashscope-result-intl.oss-cn-singapore.aliyuncs.com/result.png?Expires=9007199254740991"
-              return Effect.succeed(
-                input.respond(JSON.stringify(payload), { headers: { "content-type": "application/json" } }),
-              )
-            }),
-          ),
-        ),
-      ),
-    ),
+      expect(result.usage?.totalTokens).toBe(12)
+    }).pipe(Effect.provide(layer(() => {}, "https://example.com/result.png?Expires=9007199254740991"))),
   )
 })

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است