From fa4ec98e1d3599eadc059c2fff6a04da04106869 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:53:21 -0500 Subject: [PATCH] feat(ai): add Z.ai image generation (#37780) Co-authored-by: Aiden Cline --- packages/ai/README.md | 22 ++- packages/ai/src/protocols/zai-images.ts | 129 +++++++++++++++++ packages/ai/src/providers/index.ts | 1 + packages/ai/src/providers/zai.ts | 35 +++++ .../zai-images/generates-an-image.json | 28 ++++ packages/ai/test/image.types.ts | 16 ++- .../test/provider/zai-images.recorded.test.ts | 32 +++++ packages/ai/test/provider/zai-images.test.ts | 130 ++++++++++++++++++ 8 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 packages/ai/src/protocols/zai-images.ts 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/README.md b/packages/ai/README.md index 867027a4a4..4b93522fb1 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -110,6 +110,26 @@ 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. +Z.ai image models infer open Z.ai-native options from the selected model: + +```ts +yield * + Image.generate({ + model: ZAI.configure({ apiKey }).image("any-model-id"), + prompt, + options: { + quality: "hd", + userID: "user-123", + future_option: true, + }, + http, + }) +``` + +Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use +`application/octet-stream`. Output URLs expire after 30 days; download and persist them promptly if they must +remain available. + Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool: ```ts @@ -210,7 +230,7 @@ const gateway = CloudflareAIGateway.configure({ }).model("workers-ai/@cf/meta/llama-3.1-8b-instruct") ``` -Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. +Included providers: OpenAI, Anthropic, Google (Gemini), Google Vertex Gemini and Anthropic, Amazon Bedrock, Azure OpenAI, Cloudflare AI Gateway, Cloudflare Workers AI, GitHub Copilot, OpenRouter, xAI, Z.ai, plus generic OpenAI-compatible Chat and Responses entrypoints and an Anthropic Messages-compatible entrypoint. ### Package-like entrypoints diff --git a/packages/ai/src/protocols/zai-images.ts b/packages/ai/src/protocols/zai-images.ts new file mode 100644 index 0000000000..2a71ee6c9c --- /dev/null +++ b/packages/ai/src/protocols/zai-images.ts @@ -0,0 +1,129 @@ +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, mergeHttpOptions, mergeJsonRecords, type HttpOptions } from "../schema" +import { ProviderShared } from "./shared" + +const ADAPTER = "zai-images" +export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4" +export const PATH = "/images/generations" + +export type ZAIImageString = Known | (string & {}) + +export type ZAIImageOptions = { + readonly size?: ZAIImageString< + "1024x1024" | "768x1344" | "864x1152" | "1344x768" | "1152x864" | "1440x720" | "720x1440" + > + readonly quality?: ZAIImageString<"hd" | "standard"> + readonly userID?: string +} & Record + +type ZAIImageBody = Record & { + readonly model: string + readonly prompt: string +} + +const ZAIImageResponse = Schema.Struct({ + created: Schema.optional(Schema.Int), + id: Schema.optional(Schema.String), + request_id: Schema.optional(Schema.String), + data: Schema.Array(Schema.Struct({ url: 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 auth: AuthDefinition + readonly baseURL?: string + readonly headers?: Record + readonly http?: HttpOptions +} + +const nativeOptions = (options: ZAIImageOptions | undefined) => { + if (!options) return undefined + const { userID, ...native } = options + return { + user_id: userID, + ...native, + } +} + +const invalidOutput = (message: string) => + new LLMError({ + module: ADAPTER, + method: "generate", + reason: new InvalidProviderOutputReason({ message, route: ADAPTER }), + }) + +const applyQuery = (url: string, query: Record | undefined) => { + if (!query) return url + const next = new URL(url) + Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value)) + return next.toString() +} + +export const model = (input: ModelInput) => { + const route: ImageRoute = { + id: ADAPTER, + generate: Effect.fn("ZAIImages.generate")(function* (request: ImageRequestFor, execute) { + const http = mergeHttpOptions(request.model.http, request.http) + const requestBody = mergeJsonRecords( + { model: request.model.id, prompt: request.prompt }, + nativeOptions(request.options), + http?.body, + ) as ZAIImageBody + const text = ProviderShared.encodeJson(requestBody) + 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 Z.ai Images response")), + ) + const decoded = yield* Schema.decodeUnknownEffect(ZAIImageResponse)(payload).pipe( + Effect.mapError(() => invalidOutput("Z.ai Images returned an invalid response")), + ) + if (decoded.data.length === 0) return yield* invalidOutput("Z.ai Images returned no images") + return new ImageResponse({ + images: decoded.data.map( + (item) => + new GeneratedImage({ + mediaType: "application/octet-stream", + data: item.url, + }), + ), + providerMetadata: { + zai: { + created: decoded.created, + id: decoded.id, + requestID: decoded.request_id, + contentFilter: decoded.content_filter, + }, + }, + }) + }), + } + return ImageModel.make({ id: input.id, provider: "zai", route, http: input.http }) +} + +export const ZAIImages = { + model, +} as const 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..e170f11135 --- /dev/null +++ b/packages/ai/src/providers/zai.ts @@ -0,0 +1,35 @@ +import { ZAIImages } from "../protocols/zai-images" +import { AuthOptions, type ProviderAuthOption } from "../route/auth-options" +import { HttpOptions, ProviderID, type ModelID } from "../schema" + +export const id = ProviderID.make("zai") + +export type Config = ProviderAuthOption<"optional"> & { + readonly baseURL?: string + readonly headers?: Record + readonly http?: HttpOptions.Input +} + +export type { ZAIImageOptions } from "../protocols/zai-images" + +const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY") + +export const configure = (input: Config = {}) => { + const image = (modelID: string | ModelID) => + ZAIImages.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 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/image.types.ts b/packages/ai/test/image.types.ts index 12edd37c46..969fdf5117 100644 --- a/packages/ai/test/image.types.ts +++ b/packages/ai/test/image.types.ts @@ -6,7 +6,7 @@ import { type ImageRequestFor, type ImageRoute, } from "../src" -import { Google, OpenAI, XAI } from "../src/providers" +import { Google, OpenAI, XAI, ZAI } from "../src/providers" type GoogleLikeOptions = { readonly aspectRatio?: "1:1" | "16:9" @@ -99,6 +99,20 @@ Image.generate({ model: xai, prompt: "A lighthouse", options: { n: "2" } }) // @ts-expect-error Known xAI string options retain their value kind. Image.generate({ model: xai, prompt: "A lighthouse", options: { resolution: 2 } }) +const zai = ZAI.configure({ apiKey: "test" }).image("any-model-id") +// @ts-expect-error Image generation options are request-scoped, not provider configuration. +ZAI.configure({ image: { options: { quality: "hd" } } }) +Image.generate({ + model: zai, + prompt: "A lighthouse", + options: { quality: "future-quality", userID: "user-123", future_option: true }, +}) +Image.generate({ model: zai, prompt: "A lighthouse", options: { user_id: "raw-user" } }) +// @ts-expect-error Known Z.ai string options retain their value kind. +Image.generate({ model: zai, prompt: "A lighthouse", options: { quality: 1 } }) +// @ts-expect-error Known Z.ai user IDs retain their value kind. +Image.generate({ model: zai, prompt: "A lighthouse", options: { userID: 1 } }) + declare const generic: ImageModel Image.generate({ model: generic, prompt: "A lighthouse", options: { arbitrary: true } }) 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..a1395d1bcb --- /dev/null +++ b/packages/ai/test/provider/zai-images.recorded.test.ts @@ -0,0 +1,32 @@ +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("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.", + options: { size: "1024x1024", quality: "standard", userID: "opencode-image-test" }, + }) + + expect(response.images).toHaveLength(1) + expect(response.image?.mediaType).toBe("application/octet-stream") + 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..bea8cd3e48 --- /dev/null +++ b/packages/ai/test/provider/zai-images.test.ts @@ -0,0 +1,130 @@ +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, fixedResponse } 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: { configured: true, quality: "configured" }, query: { trace: "default" } }, + }).image("glm-image"), + prompt: "A red circle on a white background", + options: { + quality: "hd", + userID: "alias-user", + user_id: "raw-user", + future_option: true, + }, + http: { + headers: { "x-request": "yes" }, + query: { trace: "request" }, + body: { quality: "final", user_id: "final-user" }, + }, + }) + + expect(response.images).toHaveLength(1) + expect(response.image?.mediaType).toBe("application/octet-stream") + 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: "future-role", level: 4.5 }], + }, + }) + }).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", + quality: "final", + user_id: "final-user", + future_option: true, + configured: true, + }) + 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: "future-role", level: 4.5 }], + }), + { headers: { "content-type": "application/json" } }, + ) + }), + ), + ), + ), + ), + ), + ) + + it.effect("lets raw native options override aliases", () => + Image.generate({ + model: ZAI.configure({ apiKey: "test" }).image("model"), + prompt: "test", + options: { quality: "future-quality", userID: "x", user_id: "raw-user" }, + }).pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + dynamicResponse((input) => { + expect(JSON.parse(input.text)).toMatchObject({ quality: "future-quality", user_id: "raw-user" }) + return Effect.succeed( + input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), { + headers: { "content-type": "application/json" }, + }), + ) + }), + ), + ), + ), + ), + ) + + it.effect("rejects invalid response structures", () => + Effect.gen(function* () { + const model = ZAI.configure({ apiKey: "test" }).image("model") + const payloads = [ + {}, + { data: [] }, + { data: [{ b64_json: "image" }] }, + { data: [{ url: 1 }] }, + { data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: 1, level: "high" }] }, + ] + + yield* Effect.forEach(payloads, (payload) => + Image.generate({ model, prompt: "test" }).pipe( + Effect.provide( + ImageClient.layer.pipe( + Layer.provide( + fixedResponse(JSON.stringify(payload), { headers: { "content-type": "application/json" } }), + ), + ), + ), + Effect.flip, + Effect.tap((error) => Effect.sync(() => expect(error.reason._tag).toBe("InvalidProviderOutput"))), + ), + ) + }), + ) +})