feat(ai): align Alibaba image API
This commit is contained in:
parent
0baabcdc4b
commit
bee140e63b
12 changed files with 321 additions and 416 deletions
|
|
@ -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"))),
|
||||
)
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue