mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-23 02:36:57 -04:00
feat(ai): align Alibaba image API
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}) {}
|
||||
|
||||
|
||||
@@ -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 type AlibabaImageString<Known extends string> = Known | (string & {})
|
||||
|
||||
export interface QwenImageOptions {
|
||||
readonly negativePrompt?: string
|
||||
readonly promptExtend?: boolean
|
||||
readonly watermark?: boolean
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
readonly seed?: number
|
||||
} & Record<string, unknown>
|
||||
|
||||
export interface AlibabaImageOptions {
|
||||
readonly qwen?: QwenImageOptions
|
||||
readonly wan?: WanImageOptions
|
||||
}
|
||||
|
||||
declare module "../image" {
|
||||
interface ImageProviderOptions {
|
||||
readonly alibaba?: AlibabaImageOptions
|
||||
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
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
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
|
||||
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: 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,
|
||||
},
|
||||
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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 } } })
|
||||
|
||||
@@ -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")
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
+32
@@ -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\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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\"}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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" } } })
|
||||
|
||||
@@ -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,7 +111,7 @@ 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)
|
||||
@@ -110,4 +119,46 @@ describe("Alibaba Images recorded", () => {
|
||||
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))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
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")
|
||||
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,
|
||||
})
|
||||
}),
|
||||
),
|
||||
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" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects parameters overlays owned by the protocol", () =>
|
||||
it.effect("lowers ordered image inputs before text", () =>
|
||||
Image.generate({
|
||||
model: Alibaba.configure({ apiKey: "test" }).image("qwen-image-2.0"),
|
||||
prompt: "A robot",
|
||||
http: { body: { parameters: { watermark: true } } },
|
||||
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("InvalidRequest")
|
||||
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): parameters")
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse(() => Effect.die("reserved body validation must happen before the request is sent")),
|
||||
),
|
||||
),
|
||||
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("ignores finite expiration values outside the Date range", () =>
|
||||
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("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"))),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user