Compare commits

...

2 Commits

Author SHA1 Message Date
Aiden Cline c56e798b79 fix(ai): isolate Z.ai image protocol 2026-07-19 16:15:51 +00:00
Aiden Cline efda817636 feat(ai): add Z.ai image generation 2026-07-19 16:13:08 +00:00
7 changed files with 381 additions and 27 deletions
+14 -1
View File
@@ -45,6 +45,19 @@ const program = Effect.gen(function* () {
})
```
Z.ai uses the same provider-neutral response through its image facade:
```ts
import { Image } from "@opencode-ai/ai"
import { ZAI } from "@opencode-ai/ai/providers"
const model = ZAI.configure({ apiKey: process.env.ZAI_API_KEY }).image("glm-image")
const response = yield * Image.generate({ model, prompt: "A paper-cut forest at dawn" })
```
Z.ai returns temporary output URLs that expire after 30 days. Download and persist generated images 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
@@ -145,7 +158,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
+111 -26
View File
@@ -25,6 +25,13 @@ 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,
@@ -38,6 +45,15 @@ const OpenAIImageBody = Schema.Struct({
})
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
const ZAIImageBody = Schema.Struct({
model: Schema.String,
prompt: Schema.String,
size: Schema.optional(Schema.String),
quality: Schema.optional(Schema.Literals(["hd", "standard"])),
user_id: Schema.optional(Schema.String.check(Schema.isMinLength(6), Schema.isMaxLength(128))),
})
export type ZAIImageBody = Schema.Schema.Type<typeof ZAIImageBody>
const OpenAIImageResponse = Schema.Struct({
data: Schema.Array(
Schema.Struct({
@@ -58,8 +74,24 @@ const OpenAIImageResponse = Schema.Struct({
),
})
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.Literals(["assistant", "user", "history"])),
level: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3 }))),
}),
),
),
})
export interface ModelInput {
readonly id: string
readonly protocol?: ImageProtocol
readonly auth: AuthDefinition
readonly baseURL?: string
readonly headers?: Record<string, string>
@@ -71,7 +103,12 @@ 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 openaiBody = (request: ImageRequest): OpenAIImageBody => {
const options = providerOptions(request)
return {
model: request.model.id,
@@ -86,11 +123,22 @@ const body = (request: ImageRequest): OpenAIImageBody => {
}
}
const invalidOutput = (message: string) =>
const zaiBody = (request: ImageRequest): ZAIImageBody => {
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 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<string, string> | undefined) => {
@@ -100,7 +148,7 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_FIELDS = new Set([
const OPENAI_BODY_FIELDS = new Set([
"model",
"prompt",
"n",
@@ -111,13 +159,15 @@ const PROTOCOL_BODY_FIELDS = new Set([
"output_format",
"output_compression",
])
const ZAI_BODY_FIELDS = new Set(["model", "prompt", "size", "quality", "user_id"])
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
imageBody: OpenAIImageBody,
imageBody: Record<string, unknown>,
overlay: Record<string, unknown> | undefined,
ownedFields: ReadonlySet<string>,
) {
if (!overlay) return imageBody
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
const reserved = Object.keys(overlay).filter((key) => ownedFields.has(key))
if (reserved.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
@@ -126,17 +176,29 @@ 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 =
protocol === "openai"
? yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(openaiBody(request))
: yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(ZAIImageBody))(zaiBody(request))
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
const overlaidBody = yield* bodyWithOverlay(
requestBody,
http?.body,
protocol === "openai" ? OPENAI_BODY_FIELDS : ZAI_BODY_FIELDS,
)
const text = ProviderShared.encodeJson(overlaidBody)
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
const headers = yield* Auth.toEffect(input.auth)({
@@ -153,23 +215,34 @@ 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")),
)
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
const decoded = yield* (
protocol === "openai"
? Schema.decodeUnknownEffect(OpenAIImageResponse)(payload)
: Schema.decodeUnknownEffect(ZAIImageResponse)(payload)
).pipe(Effect.mapError(() => invalidOutput(adapter, `${name} Images returned an invalid response`)))
const format =
protocol === "zai"
? "jpeg"
: (("output_format" in decoded ? decoded.output_format : undefined) ??
providerOptions(request).outputFormat ??
"png")
const images = yield* Effect.forEach(decoded.data, (item, index) => {
if (item.b64_json)
if ("b64_json" in item && 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({
mediaType: `image/${format}`,
data,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
"revised_prompt" in item && item.revised_prompt !== undefined
? { openai: { revisedPrompt: item.revised_prompt } }
: undefined,
}),
),
)
@@ -179,16 +252,18 @@ export const model = (input: ModelInput) => {
mediaType: `image/${format}`,
data: item.url,
providerMetadata:
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
"revised_prompt" in item && item.revised_prompt !== undefined
? { openai: { revisedPrompt: item.revised_prompt } }
: undefined,
}),
)
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" || !("usage" in decoded) || decoded.usage === undefined
? undefined
: new Usage({
inputTokens: decoded.usage.input_tokens,
@@ -196,11 +271,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: "created" in decoded ? decoded.created : undefined,
id: "id" in decoded ? decoded.id : undefined,
requestID: "request_id" in decoded ? decoded.request_id : undefined,
contentFilter: "content_filter" in decoded ? decoded.content_filter : undefined,
},
},
})
}),
}
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 = {
+1
View File
@@ -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"
+45
View File
@@ -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<string, string>
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
@@ -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\"}"
}
}
]
}
@@ -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()
}),
)
})
@@ -0,0 +1,147 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { HttpClientRequest } from "effect/unstable/http"
import { Image, ImageClient } from "../../src"
import { OpenAI, 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: { 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" } },
)
}),
),
),
),
),
),
)
it.effect("validates Z.ai-owned request fields without reserving them for OpenAI", () =>
Effect.gen(function* () {
const invalid = yield* Image.generate({
model: ZAI.configure({ apiKey: "test", image: { providerOptions: { userID: "short" } } }).image("model"),
prompt: "test",
}).pipe(Effect.provide(ImageClient.layer.pipe(Layer.provide(fixedResponse("{}")))), Effect.flip)
expect(invalid.reason._tag).toBe("InvalidRequest")
const openaiQuality = yield* Image.generate({
model: OpenAI.configure({ apiKey: "test" }).image("model"),
prompt: "test",
providerOptions: { openai: { quality: "standard" } },
}).pipe(Effect.provide(ImageClient.layer.pipe(Layer.provide(fixedResponse("{}")))), Effect.flip)
expect(openaiQuality.reason._tag).toBe("InvalidRequest")
const zaiOverlay = yield* Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
prompt: "test",
http: { body: { user_id: "overlay-user" } },
}).pipe(Effect.provide(ImageClient.layer.pipe(Layer.provide(fixedResponse("{}")))), Effect.flip)
expect(zaiOverlay.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): user_id",
})
const request = yield* Image.generate({
model: OpenAI.configure({ apiKey: "test" }).image("model"),
prompt: "test",
http: { body: { user_id: "overlay-user" } },
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toMatchObject({ user_id: "overlay-user" })
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
)
expect(request.image?.data).toBe("https://example.test/image.jpg")
}),
)
it.effect("rejects invalid Z.ai content filter structures", () =>
Effect.gen(function* () {
const model = ZAI.configure({ apiKey: "test" }).image("model")
const payloads = [
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: "system", level: 1 }] },
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: "user", level: 1.5 }] },
{ data: [{ url: "https://example.test/image.jpg" }], content_filter: [{ role: "history", level: 4 }] },
]
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"))),
),
)
}),
)
})