diff --git a/packages/ai/README.md b/packages/ai/README.md index 6e4d87611cab..7f47809feae7 100644 --- a/packages/ai/README.md +++ b/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 diff --git a/packages/ai/src/image.ts b/packages/ai/src/image.ts index ee3ab6202ad7..0c65e43c6f55 100644 --- a/packages/ai/src/image.ts +++ b/packages/ai/src/image.ts @@ -118,6 +118,7 @@ export type ImageRequestInput = Omit< export class GeneratedImage extends Schema.Class("Image.Generated")({ mediaType: Schema.String, data: Schema.Union([Schema.String, Schema.Uint8Array]), + expiresAt: Schema.optional(Schema.String), providerMetadata: Schema.optional(ProviderMetadata), }) {} diff --git a/packages/ai/src/protocols/alibaba-images.ts b/packages/ai/src/protocols/alibaba-images.ts new file mode 100644 index 000000000000..a6e8cccabe5d --- /dev/null +++ b/packages/ai/src/protocols/alibaba-images.ts @@ -0,0 +1,229 @@ +import { Effect, Schema } from "effect" +import { Headers, HttpClientRequest } from "effect/unstable/http" +import { GeneratedImage, ImageModel, ImageResponse, type ImageRequestFor, type ImageRoute } from "../image" +import { Auth, type Definition as AuthDefinition } from "../route/auth" +import { + InvalidProviderOutputReason, + LLMError, + UnknownProviderReason, + 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 AlibabaImageString = Known | (string & {}) + +export interface AlibabaColor { + readonly hex: string + readonly ratio: string +} + +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 + readonly watermark?: boolean + readonly seed?: number +} & Record + +export type AlibabaImageBody = Record & { + readonly model: string + readonly input: { + readonly messages: ReadonlyArray<{ + readonly role: "user" + readonly content: ReadonlyArray<{ readonly image: string } | { readonly text: string }> + }> + } + readonly parameters: Record +} + +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 auth: AuthDefinition + readonly baseURL?: string + readonly headers?: Record + readonly http?: HttpOptions +} + +const nativeOptions = (options: AlibabaImageOptions | undefined) => { + if (!options) return undefined + const { resolution, negativePrompt, promptExtend, thinkingMode, colorPalette, ...native } = options + return { + size: resolution, + negative_prompt: negativePrompt, + prompt_extend: promptExtend, + thinking_mode: thinkingMode, + color_palette: colorPalette, + ...native, + } +} + +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 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 + const date = new Date(Number(value) * 1000) + return Number.isNaN(date.getTime()) ? undefined : date.toISOString() +} + +export const model = (input: ModelInput) => { + const route: ImageRoute = { + id: ADAPTER, + generate: Effect.fn("AlibabaImages.generate")(function* (request: ImageRequestFor, 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) + : 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, + 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((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) => + new GeneratedImage({ + mediaType: "image/png", + data: url, + expiresAt: expiration(url), + providerMetadata: { alibaba: { modelId: request.model.id } }, + }), + ), + 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 } }, + }) + }), + } + return ImageModel.make({ + id: input.id, + provider: "alibaba", + route, + http: input.http, + }) +} + +export const AlibabaImages = { + model, +} as const diff --git a/packages/ai/src/protocols/index.ts b/packages/ai/src/protocols/index.ts index bd3ee8eeec1d..37f1c6100584 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 000000000000..19f95c44e9d7 --- /dev/null +++ b/packages/ai/src/providers/alibaba.ts @@ -0,0 +1,35 @@ +import { AlibabaImages } from "../protocols/alibaba-images" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { HttpOptions, ProviderID } from "../schema" + +export type { AlibabaColor, AlibabaImageOptions } from "../protocols/alibaba-images" + +export const id = ProviderID.make("alibaba") + +export type Config = ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly headers?: Record + readonly http?: HttpOptions.Input +} + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "DASHSCOPE_API_KEY") + +export const configure = (input: Config = {}) => { + const image = (modelID: string) => + AlibabaImages.model({ + id: modelID, + auth: auth(input), + baseURL: input.baseURL, + headers: input.headers, + http: 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 8b794f9a3026..4c2139a6e09b 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/auth-options.types.ts b/packages/ai/test/auth-options.types.ts index c43010678995..4f0b3a995423 100644 --- a/packages/ai/test/auth-options.types.ts +++ b/packages/ai/test/auth-options.types.ts @@ -2,6 +2,7 @@ import { Config } from "effect" import { Auth } from "../src/route" import type { ModelFactory } from "../src/route/auth-options" import * as OpenAIChat from "../src/protocols/openai-chat" +import * as Alibaba from "../src/providers/alibaba" import * as AmazonBedrock from "../src/providers/amazon-bedrock" import * as Anthropic from "../src/providers/anthropic" import * as AnthropicCompatible from "../src/providers/anthropic-compatible" @@ -250,3 +251,7 @@ Cloudflare.CloudflareWorkersAI.configure({ accountId: "account", apiKey: "cf-key GitHubCopilot.configure({ baseURL: "https://copilot.test", apiKey: "copilot-key" }).model("gpt-4.1") // @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({ 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 } } }) diff --git a/packages/ai/test/exports.test.ts b/packages/ai/test/exports.test.ts index 4629100da514..b0337e8cb21a 100644 --- a/packages/ai/test/exports.test.ts +++ b/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") diff --git a/packages/ai/test/fixtures/images/edit-source-256.jpg b/packages/ai/test/fixtures/images/edit-source-256.jpg new file mode 100644 index 000000000000..d5269b3d15f5 Binary files /dev/null and b/packages/ai/test/fixtures/images/edit-source-256.jpg differ diff --git a/packages/ai/test/fixtures/recordings/alibaba-images/edits-with-qwen-image-2-0.json b/packages/ai/test/fixtures/recordings/alibaba-images/edits-with-qwen-image-2-0.json new file mode 100644 index 000000000000..02aa4e241174 --- /dev/null +++ b/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\"}" + } + } + ] +} diff --git a/packages/ai/test/fixtures/recordings/alibaba-images/edits-with-wan-2-7.json b/packages/ai/test/fixtures/recordings/alibaba-images/edits-with-wan-2-7.json new file mode 100644 index 000000000000..c6cd5a71ecd0 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/alibaba-images/edits-with-wan-2-7.json @@ -0,0 +1,32 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:alibaba-images", + "provider:alibaba", + "protocol:alibaba-images" + ], + "name": "alibaba-images/edits-with-wan-2-7", + "recordedAt": "2026-07-22T05:30:36.133Z" + }, + "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\":[{\"image\":\"data:image/jpeg;base64,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\"},{\"text\":\"Turn the black source shape into a bright orange sun icon on a pale blue 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/d0/20260722/10739b93/1784698235_08b3b146.png?Expires=1784784635&OSSAccessKeyId=%5BREDACTED%5D&Signature=%5BREDACTED%5D\",\"type\":\"image\"}],\"role\":\"assistant\"}}],\"finished\":true},\"usage\":{\"image_count\":1,\"input_tokens\":9430,\"output_tokens\":2,\"size\":\"1024*1024\",\"total_tokens\":9432},\"request_id\":\"e55e017f-f02a-92f3-8405-354906e1159a\"}" + } + } + ] +} 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 000000000000..648688aa1790 --- /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:15:36.863Z" + }, + "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/04/20260720/991d10e3/93325a03-aa95-4dd3-a395-8d7246b5e72c.png?Expires=1785083536&OSSAccessKeyId=%5BREDACTED%5D&Signature=%5BREDACTED%5D\"}],\"role\":\"assistant\"}}]},\"usage\":{\"height\":512,\"image_count\":1,\"width\":512},\"request_id\":\"9f94d544-393b-9d6f-b005-4d0ff21f6fdf\"}" + } + } + ] +} 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 000000000000..37cf28a9f901 --- /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:15:40.689Z" + }, + "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/0b/20260720/10739b93/1784477739_553aee1e.png?Expires=1784564140&OSSAccessKeyId=%5BREDACTED%5D&Signature=%5BREDACTED%5D\",\"type\":\"image\"}],\"role\":\"assistant\"}}],\"finished\":true},\"usage\":{\"image_count\":1,\"input_tokens\":31,\"output_tokens\":2,\"size\":\"1024*1024\",\"total_tokens\":33},\"request_id\":\"3eb93323-2d8f-9aa5-b010-82dd8fab80ea\"}" + } + } + ] +} diff --git a/packages/ai/test/image.types.ts b/packages/ai/test/image.types.ts index c6274963949f..dc1f44609ded 100644 --- a/packages/ai/test/image.types.ts +++ b/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" } } }) 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 000000000000..12778b62aa8e --- /dev/null +++ b/packages/ai/test/provider/alibaba-images.recorded.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test" +import { Effect, Option, Schema } from "effect" +import { Image, ImageInput } from "../../src" +import { Alibaba } from "../../src/providers" +import { dimensions } from "../lib/image" +import { recordedTests } from "../recorded-test" + +const alibaba = Alibaba.configure({ + apiKey: process.env.DASHSCOPE_API_KEY ?? "fixture", +}) + +const SIGNED_QUERY_CREDENTIALS = new Set( + [ + "AccessKeyId", + "AWSAccessKeyId", + "GoogleAccessId", + "OSSAccessKeyId", + "Signature", + "SecurityToken", + "X-Amz-Credential", + "X-Amz-Security-Token", + "X-Amz-Signature", + "X-Goog-Credential", + "X-Goog-Signature", + "X-OSS-Security-Token", + ].map((name) => name.replace(/[^a-z0-9]/gi, "").toLowerCase()), +) + +const redactSignedUrl = (value: string) => { + if (!URL.canParse(value)) return value + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:") return value + url.searchParams.forEach((_, name) => { + if (SIGNED_QUERY_CREDENTIALS.has(name.replace(/[^a-z0-9]/gi, "").toLowerCase())) + url.searchParams.set(name, "[REDACTED]") + }) + return url.toString() +} + +const redactSignedUrls = (body: string) => + Option.match(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(body), { + onNone: () => body, + onSome: (value) => + JSON.stringify( + (function redact(input: unknown): unknown { + if (typeof input === "string") return redactSignedUrl(input) + if (Array.isArray(input)) return input.map(redact) + if (input === null || typeof input !== "object") return input + return Object.fromEntries(Object.entries(input).map(([key, child]) => [key, redact(child)])) + })(value), + ), + }) + +const recorded = recordedTests({ + prefix: "alibaba-images", + provider: "alibaba", + protocol: "alibaba-images", + requires: ["DASHSCOPE_API_KEY"], + 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: { + image: + "https://example.oss.aliyuncs.com/image.png?Expires=1893456000&OSSAccessKeyId=access&Signature=signature&x-oss-security-token=token", + }, + mirrors: ["https://storage.googleapis.com/image.png?X-Goog-Credential=credential&X-Goog-Signature=signature"], + }) + const once = redactSignedUrls(body) + const twice = redactSignedUrls(once) + const parsed = JSON.parse(once) + const oss = new URL(parsed.output.image) + const gcs = new URL(parsed.mirrors[0]) + + expect(oss.searchParams.get("Expires")).toBe("1893456000") + expect(oss.searchParams.get("OSSAccessKeyId")).toBe("[REDACTED]") + expect(oss.searchParams.get("Signature")).toBe("[REDACTED]") + expect(oss.searchParams.get("x-oss-security-token")).toBe("[REDACTED]") + expect(gcs.searchParams.get("X-Goog-Credential")).toBe("[REDACTED]") + expect(gcs.searchParams.get("X-Goog-Signature")).toBe("[REDACTED]") + expect(twice).toBe(once) +}) + +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.", + options: { size: "512*512", 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.", + 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)) + }), + ) +}) 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 000000000000..17f54bd023f3 --- /dev/null +++ b/packages/ai/test/provider/alibaba-images.test.ts @@ -0,0 +1,109 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { Image, ImageClient, ImageInput } from "../../src" +import { Alibaba } from "../../src/providers" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" + +const payload = (url = "https://example.com/result.png?Expires=1893456000") => ({ + output: { choices: [{ message: { content: [{ image: url }] } }] }, + usage: { total_tokens: 12 }, + request_id: "request-alibaba", +}) + +const layer = (inspect: (body: Record) => 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" } }), + ) + }), + ), + ) + +describe("Alibaba Images", () => { + it.effect("supports open options and arbitrary models", () => + Effect.gen(function* () { + const result = yield* Image.generate({ + 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.image?.expiresAt).toBe("2030-01-01T00:00:00.000Z") + }).pipe( + Effect.provide( + 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("lowers ordered image inputs before text", () => + Image.generate({ + model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"), + prompt: "Combine these", + images: [ + ImageInput.url("https://example.com/first.png"), + ImageInput.bytes(Uint8Array.from([1, 2, 3]), "image/png"), + ], + }).pipe( + Effect.provide( + 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 provider file inputs", () => + Image.generate({ + model: Alibaba.configure({ apiKey: "test" }).image("any-model"), + prompt: "A robot", + images: [ImageInput.file("file_123")], + }).pipe( + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidRequest"))), + Effect.provide( + ImageClient.layer.pipe(Layer.provide(dynamicResponse(() => Effect.die("must fail before network I/O")))), + ), + ), + ) + + it.effect("ignores expiration values outside the Date range", () => + Effect.gen(function* () { + const result = yield* Image.generate({ + model: Alibaba.configure({ apiKey: "test" }).image("any-model"), + prompt: "A robot", + }) + expect(result.image?.expiresAt).toBeUndefined() + expect(result.usage?.totalTokens).toBe(12) + }).pipe(Effect.provide(layer(() => {}, "https://example.com/result.png?Expires=9007199254740991"))), + ) +})