From efda817636a3f9a161a5ce3d35e185a370245d0e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 19 Jul 2026 16:05:45 +0000 Subject: [PATCH] feat(ai): add Z.ai image generation --- packages/ai/src/protocols/openai-images.ts | 94 +++++++++++++++---- packages/ai/src/providers/index.ts | 1 + packages/ai/src/providers/zai.ts | 45 +++++++++ .../zai-images/generates-an-image.json | 28 ++++++ .../test/provider/zai-images.recorded.test.ts | 35 +++++++ packages/ai/test/provider/zai-images.test.ts | 73 ++++++++++++++ 6 files changed, 257 insertions(+), 19 deletions(-) create mode 100644 packages/ai/src/providers/zai.ts create mode 100644 packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json create mode 100644 packages/ai/test/provider/zai-images.recorded.test.ts create mode 100644 packages/ai/test/provider/zai-images.test.ts diff --git a/packages/ai/src/protocols/openai-images.ts b/packages/ai/src/protocols/openai-images.ts index 78ea527bd3..a9de1e7260 100644 --- a/packages/ai/src/protocols/openai-images.ts +++ b/packages/ai/src/protocols/openai-images.ts @@ -25,16 +25,24 @@ export interface OpenAIImageOptions { readonly outputCompression?: number } +export interface ZAIImageOptions { + readonly quality?: "hd" | "standard" + readonly userID?: string +} + +export type ImageProtocol = "openai" | "zai" + const OpenAIImageBody = Schema.Struct({ model: Schema.String, prompt: Schema.String, n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), - size: Schema.optional(Schema.String), - quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])), + size: Schema.optional(OpenAIImage.Size), + quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high", "hd", "standard"])), background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])), moderation: Schema.optional(Schema.Literals(["auto", "low"])), output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])), output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), + user_id: Schema.optional(Schema.String), }) export type OpenAIImageBody = Schema.Schema.Type @@ -56,10 +64,22 @@ const OpenAIImageResponse = Schema.Struct({ output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }), ), + created: Schema.optional(Schema.Int), + id: Schema.optional(Schema.String), + request_id: Schema.optional(Schema.String), + content_filter: Schema.optional( + Schema.Array( + Schema.Struct({ + role: Schema.optional(Schema.String), + level: Schema.optional(Schema.Number), + }), + ), + ), }) export interface ModelInput { readonly id: string + readonly protocol?: ImageProtocol readonly auth: AuthDefinition readonly baseURL?: string readonly headers?: Record @@ -71,7 +91,22 @@ const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({ ...request.providerOptions?.openai, }) -const body = (request: ImageRequest): OpenAIImageBody => { +const zaiOptions = (request: ImageRequest): ZAIImageOptions => ({ + ...request.model.defaults?.providerOptions?.zai, + ...request.providerOptions?.zai, +}) + +const body = (request: ImageRequest, protocol: ImageProtocol): OpenAIImageBody => { + if (protocol === "zai") { + const options = zaiOptions(request) + return { + model: request.model.id, + prompt: request.prompt, + size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`, + quality: options.quality, + user_id: options.userID, + } + } const options = providerOptions(request) return { model: request.model.id, @@ -86,11 +121,11 @@ const body = (request: ImageRequest): OpenAIImageBody => { } } -const invalidOutput = (message: string) => +const invalidOutput = (adapter: string, message: string) => new LLMError({ - module: ADAPTER, + module: adapter, method: "generate", - reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), + reason: new InvalidProviderOutputReason({ message, route: adapter }), }) const applyQuery = (url: string, query: Record | undefined) => { @@ -110,6 +145,7 @@ const PROTOCOL_BODY_FIELDS = new Set([ "moderation", "output_format", "output_compression", + "user_id", ]) const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* ( @@ -126,15 +162,22 @@ const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* ( }) export const model = (input: ModelInput) => { + const protocol = input.protocol ?? "openai" + const adapter = protocol === "openai" ? ADAPTER : "zai-images" + const name = protocol === "openai" ? "OpenAI" : "Z.ai" const route: ImageRoute = { - id: ADAPTER, + id: adapter, generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) { if (request.aspectRatio !== undefined) - return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option") + return yield* ProviderShared.invalidRequest(`${name} Images does not support the common aspectRatio option`) if (request.seed !== undefined) - return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option") + return yield* ProviderShared.invalidRequest(`${name} Images does not support the common seed option`) + if (protocol === "zai" && request.count !== undefined) + return yield* ProviderShared.invalidRequest("Z.ai Images does not support the common count option") - const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request)) + const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))( + body(request, protocol), + ) const http = mergeHttpOptions(request.model.defaults?.http, request.http) const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body) const text = ProviderShared.encodeJson(overlaidBody) @@ -153,16 +196,19 @@ export const model = (input: ModelInput) => { ), ) const payload = yield* response.json.pipe( - Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")), + Effect.mapError(() => invalidOutput(adapter, `Failed to read the ${name} Images response`)), ) const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe( - Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")), + Effect.mapError(() => invalidOutput(adapter, `${name} Images returned an invalid response`)), ) - const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png" + const format = + protocol === "zai" ? "jpeg" : (decoded.output_format ?? providerOptions(request).outputFormat ?? "png") const images = yield* Effect.forEach(decoded.data, (item, index) => { if (item.b64_json) return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe( - Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)), + Effect.mapError(() => + invalidOutput(adapter, `${name} Images result ${index} contains invalid base64 data`), + ), Effect.map( (data) => new GeneratedImage({ @@ -182,13 +228,13 @@ export const model = (input: ModelInput) => { item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } }, }), ) - return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`)) + return Effect.fail(invalidOutput(adapter, `${name} Images result ${index} has neither image data nor a URL`)) }) - if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images") + if (images.length === 0) return yield* invalidOutput(adapter, `${name} Images returned no images`) return new ImageResponse({ images, usage: - decoded.usage === undefined + protocol === "zai" || decoded.usage === undefined ? undefined : new Usage({ inputTokens: decoded.usage.input_tokens, @@ -196,11 +242,21 @@ export const model = (input: ModelInput) => { totalTokens: decoded.usage.total_tokens, providerMetadata: { openai: decoded.usage }, }), - providerMetadata: { openai: { outputFormat: format } }, + providerMetadata: + protocol === "openai" + ? { openai: { outputFormat: format } } + : { + zai: { + created: decoded.created, + id: decoded.id, + requestID: decoded.request_id, + contentFilter: decoded.content_filter, + }, + }, }) }), } - return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults }) + return ImageModel.make({ id: input.id, provider: protocol, route, defaults: input.defaults }) } export const OpenAIImages = { diff --git a/packages/ai/src/providers/index.ts b/packages/ai/src/providers/index.ts index 9838e916be..8b794f9a30 100644 --- a/packages/ai/src/providers/index.ts +++ b/packages/ai/src/providers/index.ts @@ -15,3 +15,4 @@ export * as OpenAICompatible from "./openai-compatible" export * as OpenAICompatibleResponses from "./openai-compatible-responses" export * as OpenRouter from "./openrouter" export * as XAI from "./xai" +export * as ZAI from "./zai" diff --git a/packages/ai/src/providers/zai.ts b/packages/ai/src/providers/zai.ts new file mode 100644 index 0000000000..6b9c810894 --- /dev/null +++ b/packages/ai/src/providers/zai.ts @@ -0,0 +1,45 @@ +import { OpenAIImages, type ZAIImageOptions } from "../protocols/openai-images" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { HttpOptions, ProviderID, type ModelID } from "../schema" + +export const id = ProviderID.make("zai") + +export interface ImageConfig { + readonly providerOptions?: ZAIImageOptions +} + +export type Config = ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly headers?: Record + readonly http?: HttpOptions.Input + readonly image?: ImageConfig +} + +export type { ZAIImageOptions } from "../protocols/openai-images" + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY") + +export const configure = (input: Config = {}) => { + const image = (modelID: string | ModelID) => + OpenAIImages.model({ + id: modelID, + protocol: "zai", + auth: auth(input), + baseURL: input.baseURL ?? "https://api.z.ai/api/paas/v4", + headers: input.headers, + defaults: { + providerOptions: + input.image?.providerOptions === undefined ? undefined : { zai: { ...input.image.providerOptions } }, + http: input.http === undefined ? undefined : HttpOptions.make(input.http), + }, + }) + + return { + id, + image, + configure, + } +} + +export const provider = configure() +export const image = provider.image diff --git a/packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json b/packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json new file mode 100644 index 0000000000..578da32b7c --- /dev/null +++ b/packages/ai/test/fixtures/recordings/zai-images/generates-an-image.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "metadata": { + "tags": ["prefix:zai-images", "provider:zai", "protocol:zai-images"], + "name": "zai-images/generates-an-image", + "recordedAt": "2026-07-19T16:03:55.761Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.z.ai/api/paas/v4/images/generations", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"cogview-4-250304\",\"prompt\":\"A simple flat red circle centered on a plain white background.\",\"size\":\"1024x1024\",\"quality\":\"standard\",\"user_id\":\"opencode-image-test\"}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "application/json; charset=UTF-8" + }, + "body": "{\"created\":1784477028,\"data\":[{\"url\":\"https://mfile.z.ai/1784477035500-43574eab2b6e402da9063d6ac22dfefb.png?ufileattname=202607200003482062c3bba9b04f7d_watermark.png\"}],\"id\":\"202607200003482062c3bba9b04f7d\",\"request_id\":\"202607200003482062c3bba9b04f7d\"}" + } + } + ] +} diff --git a/packages/ai/test/provider/zai-images.recorded.test.ts b/packages/ai/test/provider/zai-images.recorded.test.ts new file mode 100644 index 0000000000..010fd6aabf --- /dev/null +++ b/packages/ai/test/provider/zai-images.recorded.test.ts @@ -0,0 +1,35 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { Image } from "../../src" +import { ZAI } from "../../src/providers" +import { recordedTests } from "../recorded-test" + +const model = ZAI.configure({ + apiKey: process.env.ZAI_API_KEY ?? "fixture", + image: { providerOptions: { quality: "standard", userID: "opencode-image-test" } }, +}).image("cogview-4-250304") + +const recorded = recordedTests({ + prefix: "zai-images", + provider: "zai", + protocol: "zai-images", + requires: ["ZAI_API_KEY"], +}) + +describe("Z.ai Images recorded", () => { + recorded.effect("generates an image", () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model, + prompt: "A simple flat red circle centered on a plain white background.", + size: { width: 1024, height: 1024 }, + }) + + expect(response.images).toHaveLength(1) + expect(response.image?.mediaType).toBe("image/jpeg") + expect(response.image?.data).toBeString() + expect(response.image?.data).toStartWith("https://") + expect(response.providerMetadata?.zai).toBeDefined() + }), + ) +}) diff --git a/packages/ai/test/provider/zai-images.test.ts b/packages/ai/test/provider/zai-images.test.ts new file mode 100644 index 0000000000..2708d641fb --- /dev/null +++ b/packages/ai/test/provider/zai-images.test.ts @@ -0,0 +1,73 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { HttpClientRequest } from "effect/unstable/http" +import { Image, ImageClient } from "../../src" +import { ZAI } from "../../src/providers" +import { it } from "../lib/effect" +import { dynamicResponse } from "../lib/http" + +describe("Z.ai Images", () => { + it.effect("generates through the Z.ai Images API", () => + Effect.gen(function* () { + const response = yield* Image.generate({ + model: ZAI.configure({ + apiKey: "test", + baseURL: "https://api.z.ai.test/api/paas/v4", + headers: { "x-default": "yes" }, + http: { body: { request_metadata: "value" }, query: { trace: "default" } }, + image: { providerOptions: { quality: "standard", userID: "user-123" } }, + }).image("glm-image"), + prompt: "A red circle on a white background", + size: { width: 1280, height: 1280 }, + providerOptions: { zai: { quality: "hd" } }, + http: { headers: { "x-request": "yes" }, query: { trace: "request" } }, + }) + + expect(response.images).toHaveLength(1) + expect(response.image?.mediaType).toBe("image/jpeg") + expect(response.image?.data).toBe("https://cdn.z.ai/generated.png") + expect(response.providerMetadata).toEqual({ + zai: { + created: 1_760_335_349, + id: "generation-1", + requestID: "request-1", + contentFilter: [{ role: "assistant", level: 3 }], + }, + }) + }).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://api.z.ai.test/api/paas/v4/images/generations?trace=request") + 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: "glm-image", + prompt: "A red circle on a white background", + size: "1280x1280", + quality: "hd", + user_id: "user-123", + request_metadata: "value", + }) + return input.respond( + JSON.stringify({ + created: 1_760_335_349, + id: "generation-1", + request_id: "request-1", + data: [{ url: "https://cdn.z.ai/generated.png" }], + content_filter: [{ role: "assistant", level: 3 }], + }), + { headers: { "content-type": "application/json" } }, + ) + }), + ), + ), + ), + ), + ), + ) +})