mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-14 12:10:45 +03:00
feat(ai): add Z.AI language models (#47866)
This commit is contained in:
committed by
GitHub
Unverified
parent
1f77408ff2
commit
b3f36c0967
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { model, type Settings } from "../zai.js"
|
||||
Vendored
+28
File diff suppressed because one or more lines are too long
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+64
File diff suppressed because one or more lines are too long
+27
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
File diff suppressed because one or more lines are too long
+28
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" } },
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user