Skip to content

Commit 5a5b22e

Browse files
roomote[bot]taltasedelauna
authored
[Feat] Add GLM-5.1 to Z.AI provider models (#50)
* feat: add GLM-5.1 model support to Z.ai provider * test(zai): adding e2e test - validating output model * fix(zai): e2e tests and glim 5.1 output tokens clamping logic --------- Co-authored-by: Toray Altas <6816042+taltas@users.noreply.github.com> Co-authored-by: Elliott de Launay <edelauna@gmail.com> Co-authored-by: T <taltas@users.noreply.github.com>
1 parent 7747cdb commit 5a5b22e

7 files changed

Lines changed: 350 additions & 9 deletions

File tree

apps/vscode-e2e/AGENTS.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ Background API calls from the extension (usage collection, initialization) hit a
9999
| `OPENROUTER_API_KEY=<key> pnpm --filter @roo-code/vscode-e2e test:record` | Record mode — proxies to real API, writes `openai-*.json` |
100100
| `OPENROUTER_API_KEY=<key> pnpm --filter @roo-code/vscode-e2e test:ci` | Real-API mode — runs against live OpenRouter (for drift detection) |
101101
102+
## Tests that use a fetch interceptor instead of aimock
103+
104+
Some suites can't redirect their provider through aimock. These suites patch `globalThis.fetch` directly — the OpenAI SDK resolves `fetch` at API client construction time (which happens lazily at task start), so installing the interceptor before `api.startNewTask()` is sufficient. Installing it before `api.setConfiguration()` (as done below) is the conservative, recommended order.
105+
106+
### Z.ai GLM (`suite/providers/zai.test.ts`)
107+
108+
Z.ai doesn't expose a user-configurable base URL (it uses a fixed set of regional endpoints), so we deliberately avoided adding a hidden test-only override to the schema. The suite instead patches `globalThis.fetch` to intercept requests to `api.z.ai` and return a crafted OpenAI-compatible SSE response.
109+
110+
The suite always runs (never skips). Set `ZAI_API_KEY` to bypass the interceptor and hit the real API instead:
111+
112+
```sh
113+
# Mock mode (default — no key needed, interceptor active)
114+
pnpm --filter @roo-code/vscode-e2e test:ci:mock
115+
116+
# Live mode — bypasses interceptor, calls real Z.ai API
117+
ZAI_API_KEY=<key> TEST_FILE=zai.test pnpm --filter @roo-code/vscode-e2e test:ci
118+
```
119+
120+
When adding a new test to this suite, add a matching fixture to the `installZAiFetchInterceptor` call in `suiteSetup`. Use a short unique prefix (e.g. `"zai-glm-e2e-mytest:"`) that won't appear in `<environment_details>`.
102121
## Tests that use a non-default provider
103122
104123
If your test calls `api.setConfiguration({ apiProvider: "anthropic", ... })`, point aimock at the
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import * as assert from "assert"
2+
3+
import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
4+
5+
import { waitUntilCompleted } from "../utils"
6+
import { setDefaultSuiteTimeout } from "../test-utils"
7+
8+
// ---------------------------------------------------------------------------
9+
// Fetch interceptor
10+
//
11+
// The OpenAI SDK resolves `fetch` at client construction time
12+
// (this.fetch = options.fetch ?? getDefaultFetch()). Patching globalThis.fetch
13+
// before setConfiguration() ensures any ZAiHandler created for this suite
14+
// captures our interceptor. When ZAI_API_KEY is set the interceptor runs in
15+
// passthrough mode — it captures max_tokens from the request then forwards to
16+
// the real API, so the max_tokens assertion always runs in both modes.
17+
// ---------------------------------------------------------------------------
18+
19+
type ZAiFixture = { match: string; result: string }
20+
type ZAiRequestCapture = { maxTokens?: number }
21+
22+
function installZAiFetchInterceptor(
23+
fixtures: ZAiFixture[],
24+
capture?: ZAiRequestCapture,
25+
passthrough?: boolean,
26+
): () => void {
27+
const original = globalThis.fetch
28+
29+
globalThis.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
30+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url
31+
32+
if (url.includes("api.z.ai")) {
33+
const body = init?.body
34+
? (JSON.parse(init.body as string) as {
35+
messages?: Array<{ role: string; content: unknown }>
36+
max_tokens?: number
37+
})
38+
: {}
39+
40+
if (capture) {
41+
capture.maxTokens = body.max_tokens
42+
}
43+
44+
if (passthrough) {
45+
return original.call(globalThis, input, init as RequestInit)
46+
}
47+
48+
const messages = body.messages ?? []
49+
const lastUser = [...messages].reverse().find((m) => m.role === "user")
50+
const text =
51+
typeof lastUser?.content === "string" ? lastUser.content : JSON.stringify(lastUser?.content ?? "")
52+
53+
const fixture = fixtures.find((f) => text.includes(f.match))
54+
if (!fixture) {
55+
throw new Error(`Z.ai fetch interceptor: no fixture matched. Last user message: ${text.slice(0, 200)}`)
56+
}
57+
58+
return makeZAiSSEResponse(fixture.result)
59+
}
60+
61+
return original.call(globalThis, input, init as RequestInit)
62+
} as typeof globalThis.fetch
63+
64+
return () => {
65+
globalThis.fetch = original
66+
}
67+
}
68+
69+
function makeZAiSSEResponse(result: string): Response {
70+
const enc = new TextEncoder()
71+
const args = JSON.stringify({ result })
72+
const id = "mock-zai-001"
73+
const model = "glm-5.1"
74+
75+
const chunks = [
76+
{
77+
id,
78+
object: "chat.completion.chunk",
79+
model,
80+
choices: [{ index: 0, delta: { role: "assistant", content: null }, finish_reason: null }],
81+
},
82+
{
83+
id,
84+
object: "chat.completion.chunk",
85+
model,
86+
choices: [
87+
{
88+
index: 0,
89+
delta: {
90+
tool_calls: [
91+
{
92+
index: 0,
93+
id: "call_zai_001",
94+
type: "function",
95+
function: { name: "attempt_completion", arguments: "" },
96+
},
97+
],
98+
},
99+
finish_reason: null,
100+
},
101+
],
102+
},
103+
{
104+
id,
105+
object: "chat.completion.chunk",
106+
model,
107+
choices: [
108+
{
109+
index: 0,
110+
delta: { tool_calls: [{ index: 0, function: { arguments: args } }] },
111+
finish_reason: null,
112+
},
113+
],
114+
},
115+
{
116+
id,
117+
object: "chat.completion.chunk",
118+
model,
119+
choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }],
120+
usage: { prompt_tokens: 50, completion_tokens: 10, total_tokens: 60 },
121+
},
122+
]
123+
124+
let i = 0
125+
const stream = new ReadableStream<Uint8Array>({
126+
pull(controller) {
127+
if (i < chunks.length) {
128+
controller.enqueue(enc.encode(`data: ${JSON.stringify(chunks[i++])}\n\n`))
129+
} else {
130+
controller.enqueue(enc.encode("data: [DONE]\n\n"))
131+
controller.close()
132+
}
133+
},
134+
})
135+
136+
return new Response(stream, {
137+
status: 200,
138+
headers: { "content-type": "text/event-stream", "cache-control": "no-cache" },
139+
})
140+
}
141+
142+
// ---------------------------------------------------------------------------
143+
// Suite
144+
// ---------------------------------------------------------------------------
145+
146+
const ZAI_API_KEY = process.env.ZAI_API_KEY
147+
148+
suite("Z.ai GLM provider", function () {
149+
setDefaultSuiteTimeout(this)
150+
151+
let restoreFetch: (() => void) | undefined
152+
const requestCapture: ZAiRequestCapture = {}
153+
154+
suiteSetup(async () => {
155+
restoreFetch = installZAiFetchInterceptor(
156+
[{ match: "zai-glm-e2e:", result: "4" }],
157+
requestCapture,
158+
!!ZAI_API_KEY,
159+
)
160+
161+
await globalThis.api.setConfiguration({
162+
apiProvider: "zai" as const,
163+
zaiApiKey: ZAI_API_KEY ?? "mock-key",
164+
zaiApiLine: "international_api" as const,
165+
apiModelId: "glm-5.1",
166+
})
167+
})
168+
169+
suiteTeardown(async () => {
170+
restoreFetch?.()
171+
restoreFetch = undefined
172+
173+
const aimockUrl = process.env.AIMOCK_URL
174+
const isRecord = process.env.AIMOCK_RECORD === "true"
175+
await globalThis.api.setConfiguration({
176+
apiProvider: "openrouter" as const,
177+
openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!,
178+
openRouterModelId: "openai/gpt-4.1",
179+
...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }),
180+
})
181+
})
182+
183+
test("Should complete a task end-to-end using glm-5.1 via Z.ai provider", async () => {
184+
const api = globalThis.api
185+
const messages: ClineMessage[] = []
186+
187+
api.on(RooCodeEventName.Message, ({ message }) => {
188+
if (message.type === "say" && message.partial === false) {
189+
messages.push(message)
190+
}
191+
})
192+
193+
const taskId = await api.startNewTask({
194+
configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true },
195+
text: "zai-glm-e2e: what is 2+2? Reply with only the number.",
196+
})
197+
198+
await waitUntilCompleted({ api, taskId })
199+
200+
const completionMessage = messages.find(
201+
({ say, text }) => (say === "completion_result" || say === "text") && text?.trim() === "4",
202+
)
203+
204+
assert.ok(completionMessage, "Task should complete with the expected Z.ai GLM response")
205+
206+
// Verify max_tokens is the model's documented limit (131_072), not the 20%-of-context
207+
// heuristic cap (40_000) that guards against inaccurate OpenRouter dynamic metadata.
208+
assert.strictEqual(
209+
requestCapture.maxTokens,
210+
131_072,
211+
`max_tokens should be the documented glm-5.1 limit (131_072) but was ${requestCapture.maxTokens}`,
212+
)
213+
})
214+
})

packages/types/src/providers/zai.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ZaiApiLine } from "../provider-settings.js"
55
// https://docs.z.ai/guides/llm/glm-4-32b-0414-128k
66
// https://docs.z.ai/guides/llm/glm-4.5
77
// https://docs.z.ai/guides/llm/glm-4.6
8+
// https://docs.z.ai/guides/llm/glm-5.1
89
// https://docs.z.ai/guides/overview/pricing
910
// https://bigmodel.cn/pricing
1011

@@ -135,6 +136,21 @@ export const internationalZAiModels = {
135136
description:
136137
"GLM-5 is Zhipu's next-generation model with a 202k context window and built-in thinking capabilities. It delivers state-of-the-art reasoning, coding, and agentic performance.",
137138
},
139+
"glm-5.1": {
140+
maxTokens: 131_072,
141+
contextWindow: 200_000,
142+
supportsImages: false,
143+
supportsPromptCache: true,
144+
supportsReasoningEffort: ["disable", "medium"],
145+
reasoningEffort: "medium",
146+
preserveReasoning: true,
147+
inputPrice: 1.4,
148+
outputPrice: 4.4,
149+
cacheWritesPrice: 0,
150+
cacheReadsPrice: 0.26,
151+
description:
152+
"GLM-5.1 is Zhipu's most capable model with a 200k context window, 128k max output, and built-in thinking capabilities. It delivers top-tier reasoning, coding, and agentic performance.",
153+
},
138154
"glm-4.7-flash": {
139155
maxTokens: 16_384,
140156
contextWindow: 200_000,
@@ -311,6 +327,21 @@ export const mainlandZAiModels = {
311327
description:
312328
"GLM-5 is Zhipu's next-generation model with a 202k context window and built-in thinking capabilities. It delivers state-of-the-art reasoning, coding, and agentic performance.",
313329
},
330+
"glm-5.1": {
331+
maxTokens: 131_072,
332+
contextWindow: 204_800,
333+
supportsImages: false,
334+
supportsPromptCache: true,
335+
supportsReasoningEffort: ["disable", "medium"],
336+
reasoningEffort: "medium",
337+
preserveReasoning: true,
338+
inputPrice: 0.68,
339+
outputPrice: 2.28,
340+
cacheWritesPrice: 0,
341+
cacheReadsPrice: 0.13,
342+
description:
343+
"GLM-5.1 is Zhipu's most capable model with a 200k context window, 128k max output, and built-in thinking capabilities. It delivers top-tier reasoning, coding, and agentic performance.",
344+
},
314345
"glm-4.7-flash": {
315346
maxTokens: 16_384,
316347
contextWindow: 204_800,

src/api/providers/__tests__/zai.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,24 @@ describe("ZAiHandler", () => {
9898
expect(model.info.preserveReasoning).toBe(true)
9999
})
100100

101+
it("should return GLM-5.1 international model with thinking support and 128k max output", () => {
102+
const testModelId: InternationalZAiModelId = "glm-5.1"
103+
const handlerWithModel = new ZAiHandler({
104+
apiModelId: testModelId,
105+
zaiApiKey: "test-zai-api-key",
106+
zaiApiLine: "international_coding",
107+
})
108+
const model = handlerWithModel.getModel()
109+
expect(model.id).toBe(testModelId)
110+
expect(model.info).toEqual(internationalZAiModels[testModelId])
111+
expect(model.info.contextWindow).toBe(200_000)
112+
expect(model.info.maxTokens).toBe(131_072)
113+
expect(model.info.supportsReasoningEffort).toEqual(["disable", "medium"])
114+
expect(model.info.reasoningEffort).toBe("medium")
115+
expect(model.info.preserveReasoning).toBe(true)
116+
expect(model.info.supportsImages).toBe(false)
117+
})
118+
101119
it("should return GLM-4.5v international model with vision support", () => {
102120
const testModelId: InternationalZAiModelId = "glm-4.5v"
103121
const handlerWithModel = new ZAiHandler({
@@ -178,6 +196,24 @@ describe("ZAiHandler", () => {
178196
expect(model.info.contextWindow).toBe(131_072)
179197
})
180198

199+
it("should return GLM-5.1 China model with thinking support and 128k max output", () => {
200+
const testModelId: MainlandZAiModelId = "glm-5.1"
201+
const handlerWithModel = new ZAiHandler({
202+
apiModelId: testModelId,
203+
zaiApiKey: "test-zai-api-key",
204+
zaiApiLine: "china_coding",
205+
})
206+
const model = handlerWithModel.getModel()
207+
expect(model.id).toBe(testModelId)
208+
expect(model.info).toEqual(mainlandZAiModels[testModelId])
209+
expect(model.info.contextWindow).toBe(204_800)
210+
expect(model.info.maxTokens).toBe(131_072)
211+
expect(model.info.supportsReasoningEffort).toEqual(["disable", "medium"])
212+
expect(model.info.reasoningEffort).toBe("medium")
213+
expect(model.info.preserveReasoning).toBe(true)
214+
expect(model.info.supportsImages).toBe(false)
215+
})
216+
181217
it("should return GLM-4.7 China model with thinking support", () => {
182218
const testModelId: MainlandZAiModelId = "glm-4.7"
183219
const handlerWithModel = new ZAiHandler({

src/api/providers/zai.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
zaiApiLineConfigs,
1212
} from "@roo-code/types"
1313

14-
import { type ApiHandlerOptions, getModelMaxOutputTokens, shouldUseReasoningEffort } from "../../shared/api"
14+
import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api"
1515
import { convertToZAiFormat } from "../transform/zai-format"
1616

1717
import type { ApiHandlerCreateMessageMetadata } from "../index"
@@ -79,13 +79,11 @@ export class ZAiHandler extends BaseOpenAiCompatibleProvider<string> {
7979
) {
8080
const { id: model, info } = this.getModel()
8181

82-
const max_tokens =
83-
getModelMaxOutputTokens({
84-
modelId: model,
85-
model: info,
86-
settings: this.options,
87-
format: "openai",
88-
}) ?? undefined
82+
// Use info.maxTokens directly — Z.ai model definitions are hand-curated and accurate.
83+
// getModelMaxOutputTokens clamps to 20% of contextWindow (a guard for OpenRouter dynamic
84+
// metadata where maxTokens ≈ contextWindow), but ApiHandlerOptions omits apiProvider so
85+
// the zai bypass in api.ts never fires. glm-5.1 legitimately supports 128k output.
86+
const max_tokens = this.options.modelMaxTokens || (info.maxTokens ?? undefined)
8987

9088
const temperature = this.options.modelTemperature ?? this.defaultTemperature
9189

0 commit comments

Comments
 (0)