feat(ai): add Alibaba image generation
This commit is contained in:
parent
691a7d93c8
commit
07ce99dc9e
8 changed files with 687 additions and 0 deletions
338
packages/ai/src/protocols/alibaba-images.ts
Normal file
338
packages/ai/src/protocols/alibaba-images.ts
Normal file
|
|
@ -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<string, unknown> {
|
||||||
|
readonly negativePrompt?: string
|
||||||
|
readonly promptExtend?: boolean
|
||||||
|
readonly watermark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WanColor {
|
||||||
|
readonly hex: string
|
||||||
|
readonly ratio: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WanImageOptions extends Record<string, unknown> {
|
||||||
|
readonly resolution?: "1K" | "2K" | "4K"
|
||||||
|
readonly thinkingMode?: boolean
|
||||||
|
readonly colorPalette?: ReadonlyArray<WanColor>
|
||||||
|
readonly watermark?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AlibabaImageOptions extends Record<string, unknown> {
|
||||||
|
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<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({
|
||||||
|
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<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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, unknown>) =>
|
||||||
|
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<string, string> | 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<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")
|
||||||
|
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
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
export * as AnthropicMessages from "./anthropic-messages"
|
export * as AnthropicMessages from "./anthropic-messages"
|
||||||
|
export * as AlibabaImages from "./alibaba-images"
|
||||||
export * as BedrockConverse from "./bedrock-converse"
|
export * as BedrockConverse from "./bedrock-converse"
|
||||||
export * as Gemini from "./gemini"
|
export * as Gemini from "./gemini"
|
||||||
export * as OpenAIChat from "./openai-chat"
|
export * as OpenAIChat from "./openai-chat"
|
||||||
|
|
|
||||||
48
packages/ai/src/providers/alibaba.ts
Normal file
48
packages/ai/src/providers/alibaba.ts
Normal file
|
|
@ -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<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 =>
|
||||||
|
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
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export * as Anthropic from "./anthropic"
|
export * as Anthropic from "./anthropic"
|
||||||
export * as AnthropicCompatible from "./anthropic-compatible"
|
export * as AnthropicCompatible from "./anthropic-compatible"
|
||||||
export * as AmazonBedrock from "./amazon-bedrock"
|
export * as AmazonBedrock from "./amazon-bedrock"
|
||||||
|
export * as Alibaba from "./alibaba"
|
||||||
export * as Azure from "./azure"
|
export * as Azure from "./azure"
|
||||||
export * as Cloudflare from "./cloudflare"
|
export * as Cloudflare from "./cloudflare"
|
||||||
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
export { CloudflareAIGateway, CloudflareWorkersAI } from "./cloudflare"
|
||||||
|
|
|
||||||
28
packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/alibaba-images/generates-with-qwen-image-2-0.json
vendored
Normal file
|
|
@ -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\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
28
packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json
vendored
Normal file
28
packages/ai/test/fixtures/recordings/alibaba-images/generates-with-wan-2-7.json
vendored
Normal file
|
|
@ -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\"}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
47
packages/ai/test/provider/alibaba-images.recorded.test.ts
Normal file
47
packages/ai/test/provider/alibaba-images.recorded.test.ts
Normal file
|
|
@ -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://")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
196
packages/ai/test/provider/alibaba-images.test.ts
Normal file
196
packages/ai/test/provider/alibaba-images.test.ts
Normal file
|
|
@ -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" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue