feat(ai): add Z.AI language models (#47866)

This commit is contained in:
Aiden Cline
2026-09-07 21:55:33 -05:00
committed by GitHub
Unverified
parent 1f77408ff2
commit b3f36c0967
36 changed files with 1597 additions and 3 deletions
+1
View File
@@ -13,6 +13,7 @@
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
- Keep provider-defined string enums forward-compatible. Expose known values for autocomplete while accepting future values with `Known | (string & {})`; use `Schema.String` at runtime unless rejecting unknown values is required for correctness.
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
## Tests
+67
View File
@@ -29,6 +29,73 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
## Z.AI
`ZAI` uses the standard API. Chat Completions is the default language-model API;
the existing `.image(...)` selector provides image generation.
```ts
import { LLM } from "@opencode/ai"
import { ZAI, ZAICodingPlan } from "@opencode/ai/providers"
const zai = ZAI.configure({ apiKey: process.env.ZAI_API_KEY })
const request = LLM.request({
model: zai.model("glm-5.3"), // also zai.chat("glm-5.3")
prompt: "Explain this design.",
providerOptions: {
reasoningEffort: "high",
thinking: { type: "enabled", clear_thinking: false },
},
})
const coding = ZAICodingPlan.configure({ apiKey: process.env.ZAI_API_KEY })
const messages = LLM.request({
model: coding.messages("glm-5.3"),
prompt: "Explain this design.",
providerOptions: { effort: "high" },
})
```
The products have distinct provider identities and endpoints:
| Provider | Selector | Default base URL |
| ----------------------------------- | --------------------------- | ------------------------------------- |
| `ZAI` (`zai`) | `.model`, `.chat`, `.image` | `https://api.z.ai/api/paas/v4` |
| `ZAICodingPlan` (`zai-coding-plan`) | `.model`, `.chat` | `https://api.z.ai/api/coding/paas/v4` |
| `ZAICodingPlan` | `.messages` | `https://api.z.ai/api/anthropic/v1` |
| `ZAICodingPlan` | `.responses` | `https://api.z.ai/api/v1` |
Both read `ZAI_API_KEY` when `apiKey` is omitted and support an explicit `auth` override.
Coding Plan requires an active subscription. `baseURL` overrides the selected API's
complete base, including its version prefix. Language-model routes use HTTP/SSE.
Options retain the selected API's native semantics:
- Chat `reasoningEffort` lowers to `reasoning_effort`; Responses lowers it to `reasoning.effort`.
Messages `effort` lowers to `output_config.effort`. Omission preserves provider defaults.
- Chat `thinking` passes `type` and `clear_thinking` through unchanged. Set
`clear_thinking: false` and replay complete `response.message` values to preserve reasoning
across user messages and tool loops. The standard API defaults to clearing historical thinking;
Coding Plan documents preservation by default.
- Messages accepts `thinking: { type: "enabled" | "adaptive" | "disabled" }` without requiring
an Anthropic token budget. Coding Plan documents a disabled toggle as low-effort thinking
for GLM-5.3, with explicit effort taking precedence.
- Chat also offers `toolStream`, `doSample`, `responseFormat`, `requestID`, and `userID`.
Tool-argument streaming is enabled when tools are present on GLM-4.6/4.7/5.x;
`toolStream: false` explicitly disables it. Older model families omit the opt-in.
- Effort and thinking values remain forward-compatible strings. Their meaning is model-specific:
GLM-5.3 accepts `low`, `high`, and `max` effort and rejects disabled thinking with HTTP 400;
the direct GLM-5.2 recordings returned reasoning even with `none` and `minimal` effort,
whereas explicit `thinking.type: "disabled"` disabled it on GLM-5.2 and GLM-4.7.
Standard API recordings cover GLM-5.3 efforts and a full preserved-reasoning tool loop with
a subsequent user follow-up, GLM-5.2 efforts, older-model thinking toggles, GLM-4.5 tool calls,
GLM-5.3-Flash image input, and JSON output. Coding Plan has unit coverage for routing,
request options, and reasoning replay; successful live recordings are pending.
Package entrypoints are `@opencode/ai/providers/zai`, `zai/chat`, `zai-coding-plan`,
`zai-coding-plan/chat`, `zai-coding-plan/messages`, and `zai-coding-plan/responses`.
## Moonshot
Moonshot defaults to Chat Completions, with Messages and Responses selectors for Kimi K3:
+75
View File
@@ -0,0 +1,75 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import type { LanguageModelCompatibility, LLMRequest } from "../schema/index.js"
import { OpenAIChat } from "./openai-chat.js"
import { ProviderShared } from "./shared.js"
export type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {})
export type OptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly thinking?: {
readonly type?: "enabled" | "disabled" | (string & {})
/** False retains historical reasoning; omission preserves the endpoint's default. */
readonly clear_thinking?: boolean
}
readonly toolStream?: boolean
readonly doSample?: boolean
readonly responseFormat?: { readonly type: "text" | "json_object" | (string & {}) }
readonly requestID?: string
readonly userID?: string
}
const Options = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
thinking: Schema.optional(
Schema.Struct({ type: Schema.optional(Schema.String), clear_thinking: Schema.optional(Schema.Boolean) }),
),
toolStream: Schema.optional(Schema.Boolean),
doSample: Schema.optional(Schema.Boolean),
responseFormat: Schema.optional(Schema.Struct({ type: Schema.String })),
requestID: Schema.optional(Schema.String),
userID: Schema.optional(Schema.String),
})
const Body = Schema.Struct({
...OpenAIChat.bodyFields,
thinking: Options.fields.thinking,
do_sample: Options.fields.doSample,
response_format: Options.fields.responseFormat,
request_id: Options.fields.requestID,
user_id: Options.fields.userID,
})
const fromRequest = Effect.fn("ZAIChat.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
const body = yield* OpenAIChat.protocol.body.from(request)
return {
...body,
thinking: options.thinking,
// Tool streaming was introduced in GLM-4.6; older models must not receive the opt-in.
tool_stream:
options.toolStream ??
(body.tools?.length && /^glm-(?:4\.[67]|5(?:[.-]|$))/i.test(request.model.id) ? true : undefined),
do_sample: options.doSample,
response_format: options.responseFormat,
request_id: options.requestID,
user_id: options.userID,
}
})
export const compatibility = {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
reasoningField: "reasoning_content",
zaiToolStream: false,
} satisfies LanguageModelCompatibility
export const protocol = Protocol.make({
id: "zai-chat",
body: { schema: Body, from: fromRequest },
stream: OpenAIChat.protocol.stream,
})
export * as ZAIChat from "./zai-chat.js"
+39
View File
@@ -0,0 +1,39 @@
import { Effect, Schema } from "effect"
import { Protocol } from "../route/protocol.js"
import { LLMRequest } from "../schema/index.js"
import { AnthropicMessages } from "./anthropic-messages.js"
import { ProviderShared } from "./shared.js"
import type { ZAIChat } from "./zai-chat.js"
export type OptionsInput = {
readonly effort?: ZAIChat.ReasoningEffort
readonly thinking?: { readonly type: "enabled" | "adaptive" | "disabled" | (string & {}) }
}
const Options = Schema.Struct({
effort: Schema.optional(Schema.String),
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
})
const Body = Schema.Struct({
...AnthropicMessages.AnthropicMessagesBody.fields,
thinking: Options.fields.thinking,
})
const fromRequest = Effect.fn("ZAIMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(request.providerOptions ?? {})
// Z.AI accepts enabled thinking without Anthropic's mandatory token budget.
const body = yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(request, {
providerOptions: { ...request.providerOptions, thinking: undefined },
}),
)
return { ...body, thinking: options.thinking }
})
export const protocol = Protocol.make({
id: "zai-messages",
body: { schema: Body, from: fromRequest },
stream: AnthropicMessages.protocol.stream,
})
export * as ZAIMessages from "./zai-messages.js"
+1
View File
@@ -27,3 +27,4 @@ export * as OpenRouter from "./openrouter.js"
export * as TogetherAI from "./togetherai.js"
export * as XAI from "./xai.js"
export * as ZAI from "./zai.js"
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -0,0 +1,92 @@
import type { ProviderPackage } from "../provider-package.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { ZAIChat } from "../protocols/zai-chat.js"
import { ZAIMessages } from "../protocols/zai-messages.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai-coding-plan")
export type ChatOptionsInput = ZAIChat.OptionsInput
export type MessagesOptionsInput = ZAIMessages.OptionsInput
export type ResponsesOptionsInput = { readonly reasoningEffort?: ZAIChat.ReasoningEffort }
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's complete base URL. */
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const chatRoute = Route.make({
id: "zai-coding-chat",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.z.ai/api/coding/paas/v4" }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "zai-coding-messages",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.z.ai/api/anthropic/v1" }),
framing: AnthropicMessages.framing,
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
const responsesRoute = Route.make({
id: "zai-coding-responses",
provider: id,
providerMetadataKey: "zai",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.z.ai/api/v1" }),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, responsesRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const defaults = {
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: AuthOptions.bearer(input, "ZAI_API_KEY"),
}
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const messages = (modelID: string | ModelID) =>
messagesRoute
.with(defaults)
.model<MessagesOptionsInput>({ id: modelID, compatibility: { requireSignature: false } })
const responses = (modelID: string | ModelID) =>
responsesRoute.with(defaults).model<ResponsesOptionsInput>({ id: modelID })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../zai-coding-plan.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { ZAICodingPlan } from "../zai-coding-plan.js"
export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { ZAICodingPlan } from "../zai-coding-plan.js"
export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
+50 -3
View File
@@ -1,20 +1,53 @@
import type { ProviderPackage } from "../provider-package.js"
import { ZAIChat } from "../protocols/zai-chat.js"
import { ZAIImages } from "../protocols/zai-images.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("zai")
export type Config = ProviderAuthOption<"optional"> & {
export type ChatOptionsInput = ZAIChat.OptionsInput
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly headers?: Record<string, string>
readonly http?: HttpOptions.Input
readonly providerOptions?: ChatOptionsInput
}
export type { ZAIImageOptions } from "../protocols/zai-images.js"
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "ZAI_API_KEY")
const chatRoute = Route.make({
id: "zai-chat",
provider: id,
providerMetadataKey: "zai",
protocol: ZAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.z.ai/api/paas/v4" }),
framing: OpenAIChat.framing,
})
export const routes = [chatRoute]
export const configure = (input: Config = {}) => {
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
const chat = (modelID: string | ModelID) =>
chatRoute
.with({
...rest,
endpoint: baseURL === undefined ? undefined : { baseURL },
auth: auth(input),
})
.model<ChatOptionsInput>({ id: modelID, compatibility: ZAIChat.compatibility })
const image = (modelID: string | ModelID) =>
ZAIImages.model({
id: modelID,
@@ -26,6 +59,8 @@ export const configure = (input: Config = {}) => {
return {
id,
model: chat,
chat,
image,
configure,
}
@@ -33,3 +68,15 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const image = provider.image
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).model(modelID)
export * as ZAI from "./zai.js"
+1
View File
@@ -0,0 +1 @@
export { model, type Settings } from "../zai.js"
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "text", "thinking", "usage"],
"name": "zai-chat/glm-4-7-streams-thinking-disabled",
"recordedAt": "2026-09-08T00:59:30.888Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-4.7\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\",\"clear_thinking\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=UTF-8"
},
"body": "data: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"3\"}}]}\n\ndata: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"7887\"}}]}\n\ndata: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\ndata: {\"id\":\"20260908085928f9beb3885fd04e69\",\"created\":1788829168,\"object\":\"chat.completion.chunk\",\"model\":\"glm-4.7\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}],\"usage\":{\"prompt_tokens\":22,\"completion_tokens\":3,\"total_tokens\":25,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "text", "thinking", "usage"],
"name": "zai-chat/glm-5-2-streams-thinking-disabled",
"recordedAt": "2026-09-08T00:59:35.300Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-5.2\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\",\"clear_thinking\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=UTF-8"
},
"body": "data: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"3\"}}]}\n\ndata: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"7887\"}}]}\n\ndata: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}]}\n\ndata: {\"id\":\"20260908085934bc85f3b6601c42e1\",\"created\":1788829174,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.2\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}],\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":3,\"total_tokens\":26,\"prompt_tokens_details\":{\"cached_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "thinking", "model-specific", "error"],
"name": "zai-chat/glm-5-3-rejects-disabled-thinking",
"recordedAt": "2026-09-08T01:15:19.148Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-5.3\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 400,
"headers": {},
"body": "eyJlcnJvciI6eyJjb2RlIjoiMTIxMCIsIm1lc3NhZ2UiOiJUaGlzIG1vZGVsIGFsd2F5cyBlbmdhZ2VzIGluIHRoaW5raW5nIGFuZCBjYW5ub3QgYmUgZGlzYWJsZWQ7IHBsZWFzZSB1c2UgbG93LCBoaWdoLCBvciBtYXgifX0=",
"bodyEncoding": "base64"
}
}
]
}
@@ -0,0 +1,28 @@
{
"version": 1,
"metadata": {
"tags": ["prefix:zai-chat", "provider:zai", "protocol:zai-chat", "structured-output"],
"name": "zai-chat/glm-5-3-returns-a-json-object",
"recordedAt": "2026-09-08T01:00:04.691Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.z.ai/api/paas/v4/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"glm-5.3\",\"messages\":[{\"role\":\"user\",\"content\":\"Return a JSON object with one key \\\"city\\\" set to the capital city of France.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_tokens\":4096,\"response_format\":{\"type\":\"json_object\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream;charset=UTF-8"
},
"body": "data: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\\n\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"{\\\"\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"city\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\\\": \\\"Paris\\\"}\\n\"}}]}\n\ndata: {\"id\":\"20260908090004d403d46ed31e4758\",\"created\":1788829204,\"object\":\"chat.completion.chunk\",\"model\":\"glm-5.3\",\"choices\":[{\"index\":0,\"finish_reason\":\"stop\",\"delta\":{\"role\":\"assistant\",\"content\":\"\"}}],\"usage\":{\"prompt_tokens\":91,\"completion_tokens\":12,\"total_tokens\":103,\"prompt_tokens_details\":{\"cached_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,32 @@
import { LLM } from "../../src/index.js"
import { ZAI, ZAICodingPlan } from "../../src/providers.js"
LLM.request({
model: ZAI.chat("glm-5.3"),
providerOptions: { thinking: { clear_thinking: false }, reasoningEffort: "max" },
})
LLM.request({
model: ZAI.chat("glm-5.2"),
providerOptions: { thinking: { type: "disabled" }, toolStream: false, responseFormat: { type: "json_object" } },
})
LLM.request({
model: ZAICodingPlan.messages("glm-5.3"),
providerOptions: { thinking: { type: "enabled" }, effort: "high" },
})
LLM.request({ model: ZAICodingPlan.responses("glm-5.3"), providerOptions: { reasoningEffort: "future-effort" } })
LLM.request({
model: ZAI.chat("glm-5.3"),
// @ts-expect-error clear_thinking is a boolean.
providerOptions: { thinking: { clear_thinking: "false" } },
})
LLM.request({
model: ZAICodingPlan.messages("glm-5.3"),
// @ts-expect-error Messages uses effort.
providerOptions: { reasoningEffort: "high" },
})
LLM.request({
model: ZAICodingPlan.responses("glm-5.3"),
// @ts-expect-error Responses uses reasoning effort, not Chat thinking controls.
providerOptions: { thinking: { type: "enabled" } },
})
+41
View File
@@ -45,6 +45,12 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
import("@opencode/ai/providers/zai"),
import("@opencode/ai/providers/zai/chat"),
import("@opencode/ai/providers/zai-coding-plan"),
import("@opencode/ai/providers/zai-coding-plan/chat"),
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -105,6 +111,41 @@ describe("provider package entrypoints", () => {
})
})
test("maps ZAI and Coding Plan entrypoints onto distinct provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/zai"),
import("@opencode/ai/providers/zai/chat"),
import("@opencode/ai/providers/zai-coding-plan"),
import("@opencode/ai/providers/zai-coding-plan/chat"),
import("@opencode/ai/providers/zai-coding-plan/messages"),
import("@opencode/ai/providers/zai-coding-plan/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
expect(modules[2].model).toBe(modules[3].model)
const routes = [
"zai-chat",
"zai-chat",
"zai-coding-chat",
"zai-coding-chat",
"zai-coding-messages",
"zai-coding-responses",
]
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/custom",
headers: { "x-test": "fixture" },
body: { extension: true },
}
modules.forEach((module, index) => {
const selected = module.model("glm-5.3", settings)
expect(selected.provider).toBe(index < 2 ? "zai" : "zai-coding-plan")
expect(selected.route.id).toBe(routes[index])
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
expect(selected.route.defaults.headers).toEqual(settings.headers)
expect(selected.route.defaults.http?.body).toEqual(settings.body)
})
})
test("maps DeepInfra package settings onto its native executable model", async () => {
const DeepInfra = await import("@opencode/ai/providers/deepinfra")
const settings = {
@@ -0,0 +1,156 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { ZAI } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.ZAI_API_KEY ?? "fixture"
const recorded = recordedTests({ prefix: "zai-chat", provider: "zai", protocol: "zai-chat", requires: ["ZAI_API_KEY"] })
recorded.effect.with(
"GLM 5.3 rejects disabled thinking",
{ tags: ["thinking", "model-specific", "error"] },
() =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { thinking: { type: "disabled" } } }).chat("glm-5.3"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason.http?.status).toBe(400)
expect(error.message).toContain("always engages in thinking and cannot be disabled")
}),
120_000,
)
for (const modelID of ["glm-4.7", "glm-5.2"]) {
for (const type of ["enabled", "disabled"] as const) {
recorded.effect.with(
`${modelID} streams thinking ${type}`,
{ tags: ["text", "thinking", "usage"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { thinking: { type, clear_thinking: true } } }).chat(
modelID,
),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length > 0).toBe(type === "enabled")
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
}
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const) {
recorded.effect.with(
`GLM 5.2 streams ${effort} effort`,
{ tags: ["text", "effort", "usage"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: effort } }).chat("glm-5.2"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
)
expect(response.text.replaceAll(",", "")).toContain("37887")
// The direct API still returned reasoning for none/minimal in these recordings.
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
recorded.effect.with(
"GLM 5.3 Flash reads image bytes",
{ tags: ["image", "reasoning"] },
() =>
Effect.gen(function* () {
const bytes = yield* Effect.promise(() =>
Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(),
)
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: "low" } }).chat("glm-5.3-flash"),
messages: [
Message.user([
{ type: "text", text: "Read the three words in this image. Reply with only the words in order." },
{ type: "media", mediaType: "image/png", data: bytes },
]),
],
generation: { maxTokens: 4096 },
}),
)
expect(response.text.toLowerCase()).toContain("jiggling restroom prison")
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}),
120_000,
)
recorded.effect.with(
"GLM 5.3 returns a JSON object",
{ tags: ["structured-output"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({
apiKey,
providerOptions: { reasoningEffort: "low", responseFormat: { type: "json_object" } },
}).chat("glm-5.3"),
prompt: 'Return a JSON object with one key "city" set to the capital city of France.',
generation: { maxTokens: 4096 },
}),
)
expect(JSON.parse(response.text)).toEqual({ city: "Paris" })
expect(response.finishReason.normalized).toBe("stop")
}),
120_000,
)
recorded.effect.with(
"GLM 4.5 calls a tool without streaming arguments",
{ tags: ["tool", "legacy"] },
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: ZAI.configure({ apiKey }).chat("glm-4.5"),
prompt: "Use get_weather to look up the current weather in Paris.",
tools: [
ToolDefinition.make({
name: "get_weather",
description: "Get weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
},
}),
],
generation: { maxTokens: 4096 },
}),
)
expect(response.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(response.finishReason.normalized).toBe("tool-calls")
}),
120_000,
)
@@ -0,0 +1,113 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/index.js"
import { ZAI } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.ZAI_API_KEY ?? "fixture"
const recorded = recordedTests({ prefix: "zai-chat", provider: "zai", protocol: "zai-chat", requires: ["ZAI_API_KEY"] })
const weather = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
},
})
for (const effort of [undefined, "low", "high", "max"] as const) {
recorded.effect.with(
`GLM 5.3 streams ${effort ?? "default"} effort`,
{ tags: ["text", "reasoning", "usage"] },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: ZAI.configure({ apiKey, providerOptions: { reasoningEffort: effort } }).model("glm-5.3"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.thinking).toBeUndefined()
expect(compiled.body.reasoning_effort).toBe(effort)
const response = yield* LLMClient.generate(request)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
expectUsage(response)
}),
120_000,
)
}
recorded.effect.with(
"GLM 5.3 preserves reasoning through a tool loop and follow-up",
{ tags: ["tool", "tool-loop", "reasoning", "continuation"] },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: ZAI.configure({
apiKey,
providerOptions: { reasoningEffort: "high", thinking: { type: "enabled", clear_thinking: false } },
}).chat("glm-5.3"),
prompt:
"We have a budget of 38000 dollars for 219 trips costing 173 dollars each. Calculate whether that is affordable. If it is, use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.",
tools: [weather],
generation: { maxTokens: 4096 },
})
const first = yield* LLMClient.generate(request)
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.reasoning.length).toBeGreaterThan(0)
expect(first.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
expectUsage(first)
const continuation = LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
),
],
})
const compiled = yield* compileRequest(continuation)
expect(compiled.body.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning_content: first.reasoning })]),
)
const second = yield* LLMClient.generate(continuation)
expect(second.text.toLowerCase()).toContain("sunny")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
expectUsage(second)
const followUp = LLMRequest.update(continuation, {
messages: [
...continuation.messages,
second.message,
Message.user("What temperature did the tool report? Reply with only the temperature."),
],
})
const replay = yield* compileRequest(followUp)
expect(replay.body.messages).toEqual(
expect.arrayContaining(
[first, second].map((response) =>
expect.objectContaining({ role: "assistant", reasoning_content: response.reasoning }),
),
),
)
const third = yield* LLMClient.generate(followUp)
expect(third.text).toContain("18")
expect(third.toolCalls).toHaveLength(0)
expect(third.finishReason.normalized).toBe("stop")
expectUsage(third)
}),
180_000,
)
function expectUsage(response: LLMResponse) {
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}
+301
View File
@@ -0,0 +1,301 @@
import { expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, LLM, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/index.js"
import { ZAI, ZAICodingPlan } from "../../src/providers.js"
import { compileRequest } from "../../src/route/client.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const tool = ToolDefinition.make({
name: "lookup",
description: "Look up a value",
inputSchema: { type: "object", properties: { key: { type: "string" } } },
})
it.effect("ZAI owns standard and Coding Plan endpoints and bearer credentials", () =>
Effect.gen(function* () {
expect(ZAI.provider.model).toBe(ZAI.provider.chat)
expect(ZAICodingPlan.provider.model).toBe(ZAICodingPlan.provider.chat)
for (const item of [
{ model: ZAI.chat("glm-5.3"), path: "/paas/v4/chat/completions", provider: "zai" },
{ model: ZAICodingPlan.chat("glm-5.3"), path: "/coding/paas/v4/chat/completions", provider: "zai-coding-plan" },
{ model: ZAICodingPlan.messages("glm-5.3"), path: "/anthropic/v1/messages", provider: "zai-coding-plan" },
{ model: ZAICodingPlan.responses("glm-5.3"), path: "/v1/responses", provider: "zai-coding-plan" },
]) {
const request = LLM.request({ model: item.model, prompt: "Hello" })
const compiled = yield* compileRequest(request)
const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString()
expect(url).toBe(`https://api.z.ai/api${item.path}`)
expect(item.model.provider).toBe(item.provider)
expect(item.model.route.providerMetadataKey).toBe("zai")
expect(compiled.body.thinking).toBeUndefined()
expect(compiled.body.reasoning_effort).toBeUndefined()
expect(compiled.body.reasoning).toBeUndefined()
expect(compiled.body.output_config).toBeUndefined()
const headers = yield* item.model.route.auth.apply({
request,
method: "POST",
url,
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer fixture")
}
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { ZAI_API_KEY: "fixture" } })))),
)
it.effect("ZAI Chat retains native thinking fields and scopes tool streaming to supported models", () =>
Effect.gen(function* () {
for (const configure of [ZAI.configure, ZAICodingPlan.configure]) {
const provider = configure({
baseURL: "https://gateway.example/custom",
auth: Auth.header("x-test-key", "fixture"),
})
for (const modelID of [
"glm-4.5",
"glm-4.5-air",
"glm-4.5-x",
"glm-4.6",
"glm-4.7-flash",
"glm-5.3",
"glm-5.3-flash",
"future-model",
]) {
const result = yield* compileRequest(
LLM.request({
model: provider.chat(modelID),
prompt: "Hello",
generation: { maxTokens: 123 },
tools: [tool],
providerOptions: { thinking: { type: "enabled", clear_thinking: false } },
}),
)
expect(result.body).toMatchObject({
model: modelID,
max_tokens: 123,
thinking: { type: "enabled", clear_thinking: false },
})
expect(result.body.tool_stream).toBe(
["glm-4.6", "glm-4.7-flash", "glm-5.3", "glm-5.3-flash"].includes(modelID) ? true : undefined,
)
expect(result.body.max_completion_tokens).toBeUndefined()
expect(result.body.store).toBeUndefined()
expect(result.body.tools).toEqual([
expect.objectContaining({ function: expect.not.objectContaining({ strict: expect.anything() }) }),
])
}
for (const thinking of [
undefined,
{ type: "disabled" },
{ clear_thinking: false },
{ type: "enabled", clear_thinking: true },
]) {
const result = yield* compileRequest(
LLM.request({
model: provider.chat("glm-5.2"),
providerOptions: {
thinking,
toolStream: false,
doSample: false,
responseFormat: { type: "json_object" },
requestID: "request-1",
userID: "user-1",
},
tools: [tool],
}),
)
expect(result.body.thinking).toEqual(thinking)
expect(result.body).toMatchObject({
tool_stream: false,
do_sample: false,
response_format: { type: "json_object" },
request_id: "request-1",
user_id: "user-1",
})
}
}
}),
)
it.effect("ZAI lowers effort using the selected native API without inventing thinking settings", () =>
Effect.gen(function* () {
for (const effort of ["none", "minimal", "low", "medium", "high", "xhigh", "max", "future-effort"]) {
const standard = yield* compileRequest(
LLM.request({
model: ZAI.configure({ apiKey: "fixture" }).chat("glm-5.3"),
providerOptions: { reasoningEffort: effort },
}),
)
const coding = ZAICodingPlan.configure({ apiKey: "fixture" })
const chat = yield* compileRequest(
LLM.request({ model: coding.chat("glm-5.3"), providerOptions: { reasoningEffort: effort } }),
)
const messages = yield* compileRequest(
LLM.request({ model: coding.messages("glm-5.3"), providerOptions: { effort } }),
)
const responses = yield* compileRequest(
LLM.request({ model: coding.responses("glm-5.3"), providerOptions: { reasoningEffort: effort } }),
)
expect(standard.body.reasoning_effort).toBe(effort)
expect(chat.body.reasoning_effort).toBe(effort)
expect(messages.body.output_config).toEqual({ effort })
expect(responses.body.reasoning).toEqual({ effort })
for (const result of [standard, chat, messages, responses]) expect(result.body.thinking).toBeUndefined()
}
for (const type of ["enabled", "adaptive", "disabled"]) {
const result = yield* compileRequest(
LLM.request({
model: ZAICodingPlan.configure({ apiKey: "fixture", providerOptions: { thinking: { type } } }).messages(
"glm-5.3",
),
}),
)
expect(result.body.thinking).toEqual({ type })
}
}),
)
it.effect("ZAI rejects invalid typed thinking options before execution", () =>
Effect.gen(function* () {
const model = ZAI.configure({ apiKey: "fixture" })
.chat("glm-5.3")
.route.with({ providerOptions: { thinking: { clear_thinking: "false" } } })
.model({ id: "glm-5.3" })
const error = yield* compileRequest(LLM.request({ model })).pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.message).toContain("clear_thinking")
}),
)
it.effect("ZAI preserves unsigned and signed Messages reasoning", () =>
Effect.gen(function* () {
const result = yield* compileRequest(
LLM.request({
model: ZAICodingPlan.configure({ apiKey: "fixture" }).messages("glm-5.3"),
messages: [
Message.user("Hello"),
Message.assistant([
ReasoningPart.make({ type: "reasoning", text: "unsigned" }),
ReasoningPart.make({
type: "reasoning",
text: "signed",
providerMetadata: { zai: { signature: "opaque" } },
}),
]),
],
}),
)
expect(result.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: [
{ type: "thinking", thinking: "unsigned", signature: "" },
{ type: "thinking", thinking: "signed", signature: "opaque" },
],
}),
]),
)
}),
)
it.effect("ZAI applies configured options, request options, and final body overlays in order", () =>
LLMClient.generate(
LLM.request({
model: ZAI.configure({
apiKey: "fixture",
baseURL: "https://gateway.example/custom",
providerOptions: { thinking: { type: "enabled", clear_thinking: true }, reasoningEffort: "high" },
}).chat("glm-5.3"),
prompt: "Hello",
providerOptions: { thinking: { clear_thinking: false }, reasoningEffort: "low" },
http: { body: { reasoning_effort: "max", extension: true } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://gateway.example/custom/chat/completions")
expect(input.request.headers.authorization).toBe("Bearer fixture")
expect(JSON.parse(input.text)).toMatchObject({
thinking: { type: "enabled", clear_thinking: false },
reasoning_effort: "max",
extension: true,
})
return input.respond(
sseEvents({ choices: [{ delta: { content: "Hello" }, finish_reason: "stop" }] }, "[DONE]"),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
),
)
it.effect("ZAI Coding Responses uses HTTP with a supplied WebSocket executor", () =>
LLMClient.generate(
LLM.request({ model: ZAICodingPlan.configure({ apiKey: "fixture" }).responses("glm-5.3"), prompt: "Hello" }),
{
webSocket: { execute: () => Effect.die("Unexpected WebSocket") },
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://api.z.ai/api/v1/responses")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("ZAI Coding Messages serializes enabled thinking without a token budget", () =>
LLMClient.generate(
LLM.request({
model: ZAICodingPlan.configure({
apiKey: "fixture",
baseURL: "https://gateway.example/messages/v1",
providerOptions: { thinking: { type: "enabled" }, effort: "max" },
}).messages("glm-5.3"),
prompt: "Hello",
providerOptions: { effort: "high" },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://gateway.example/messages/v1/messages")
expect(input.request.headers.authorization).toBe("Bearer fixture")
expect(input.request.headers["anthropic-version"]).toBe("2023-06-01")
expect(JSON.parse(input.text)).toMatchObject({
thinking: { type: "enabled" },
output_config: { effort: "high" },
})
return input.respond(
sseEvents(
{
type: "message_start",
message: {
id: "msg_fixture",
role: "assistant",
model: "glm-5.3",
content: [],
usage: { input_tokens: 1, output_tokens: 0 },
},
},
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
),
)