Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions packages/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/ai/src/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}) {}

Expand Down
229 changes: 229 additions & 0 deletions packages/ai/src/protocols/alibaba-images.ts
Original file line number Diff line number Diff line change
@@ -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 extends string> = 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<AlibabaColor>
readonly watermark?: boolean
readonly seed?: number
} & Record<string, unknown>

export type AlibabaImageBody = Record<string, unknown> & {
readonly model: string
readonly input: {
readonly messages: ReadonlyArray<{
readonly role: "user"
readonly content: ReadonlyArray<{ readonly image: string } | { readonly text: string }>
}>
}
readonly parameters: Record<string, unknown>
}

const 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<string, string>
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<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 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<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,
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<AlibabaImageOptions>({
id: input.id,
provider: "alibaba",
route,
http: input.http,
})
}

export const AlibabaImages = {
model,
} as const
1 change: 1 addition & 0 deletions packages/ai/src/protocols/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
35 changes: 35 additions & 0 deletions packages/ai/src/providers/alibaba.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
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
1 change: 1 addition & 0 deletions packages/ai/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 5 additions & 0 deletions packages/ai/test/auth-options.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 } } })
11 changes: 10 additions & 1 deletion packages/ai/test/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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", () => {
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading