feat(ai): add Z.ai image generation
This commit is contained in:
committed by
𝓛𝓲𝓽𝓽𝓵𝓮 𝓕𝓻𝓪𝓷𝓴
parent
bef6cfbffe
commit
efda817636
@@ -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<typeof OpenAIImageBody>
|
||||
|
||||
@@ -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<string, string>
|
||||
@@ -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<string, string> | 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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user