Skip to content

Commit 99c7ed1

Browse files
feat(zai): expose configurable max output tokens for GLM models (#161) (#274)
* feat(zai): expose configurable max output tokens for GLM models (#161) Add a standalone "max output tokens" slider for models that advertise `supportsMaxTokens` (e.g. Z.ai GLM) but do not surface a reasoning budget, and send the chosen value to the provider as `max_tokens`. - ThinkingBudget: extract the max-tokens slider into a small render helper shared by the standalone control and the reasoning-budget branch, and surface it for binary-reasoning models that also support a configurable max. - getModelMaxOutputTokens: honor the user's `modelMaxTokens` override for `supportsMaxTokens` models (capped at the model ceiling) instead of the 20% context-window clamp, so the runtime budget matches what is sent to the provider. - ProviderSettingsManager.export(): preserve `modelMaxTokens` for configurable-max models while dropping it for models that support neither reasoning budgets nor a configurable max. * Update src/shared/api.ts * Update webview-ui/src/components/settings/ThinkingBudget.tsx --------- Co-authored-by: Armando Vaquera <263793884+proyectoauraorg@users.noreply.github.com> Co-authored-by: edelauna <54631123+edelauna@users.noreply.github.com>
1 parent 9193941 commit 99c7ed1

9 files changed

Lines changed: 391 additions & 68 deletions

File tree

packages/types/src/model.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,10 @@ export const modelInfoSchema = z.object({
8181
promptCacheRetention: z.enum(["in_memory", "24h"]).optional(),
8282
// Capability flag to indicate whether the model supports an output verbosity parameter
8383
supportsVerbosity: z.boolean().optional(),
84+
// Capability flag to indicate whether the model exposes a user-configurable max output
85+
// tokens control in settings. When set, the settings UI surfaces a slider that persists
86+
// `modelMaxTokens`; when the user leaves it unset, the default output clamp is used.
87+
supportsMaxTokens: z.boolean().optional(),
8488
supportsReasoningBudget: z.boolean().optional(),
8589
// Capability flag to indicate whether the model supports simple on/off binary reasoning
8690
supportsReasoningBinary: z.boolean().optional(),

packages/types/src/providers/zai.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export const internationalZAiModels = {
142142
contextWindow: 200_000,
143143
supportsImages: false,
144144
supportsPromptCache: true,
145+
supportsMaxTokens: true,
145146
supportsReasoningEffort: ["disable", "medium"],
146147
reasoningEffort: "medium",
147148
preserveReasoning: true,
@@ -157,6 +158,7 @@ export const internationalZAiModels = {
157158
contextWindow: 202_752,
158159
supportsImages: false,
159160
supportsPromptCache: true,
161+
supportsMaxTokens: true,
160162
supportsReasoningEffort: ["disable", "medium"],
161163
reasoningEffort: "medium",
162164
preserveReasoning: true,
@@ -348,6 +350,7 @@ export const mainlandZAiModels = {
348350
contextWindow: 204_800,
349351
supportsImages: false,
350352
supportsPromptCache: true,
353+
supportsMaxTokens: true,
351354
supportsReasoningEffort: ["disable", "medium"],
352355
reasoningEffort: "medium",
353356
preserveReasoning: true,
@@ -363,6 +366,7 @@ export const mainlandZAiModels = {
363366
contextWindow: 202_752,
364367
supportsImages: false,
365368
supportsPromptCache: true,
369+
supportsMaxTokens: true,
366370
supportsReasoningEffort: ["disable", "medium"],
367371
reasoningEffort: "medium",
368372
preserveReasoning: true,

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

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,46 @@ describe("ZAiHandler", () => {
503503
)
504504
})
505505

506+
it("should advertise supportsMaxTokens for configurable GLM models", () => {
507+
expect(internationalZAiModels["glm-5.1"].supportsMaxTokens).toBe(true)
508+
expect(internationalZAiModels["glm-5-turbo"].supportsMaxTokens).toBe(true)
509+
expect(mainlandZAiModels["glm-5.1"].supportsMaxTokens).toBe(true)
510+
expect(mainlandZAiModels["glm-5-turbo"].supportsMaxTokens).toBe(true)
511+
// Models without a configurable output budget should not advertise the flag.
512+
expect((internationalZAiModels["glm-4.7"] as { supportsMaxTokens?: boolean }).supportsMaxTokens).toBe(
513+
undefined,
514+
)
515+
})
516+
517+
it("should honor an explicit modelMaxTokens override instead of the 20% clamp", async () => {
518+
const handlerWithModel = new ZAiHandler({
519+
apiModelId: "glm-5.1",
520+
zaiApiKey: "test-zai-api-key",
521+
zaiApiLine: "international_coding",
522+
modelMaxTokens: 100_000,
523+
})
524+
525+
mockCreate.mockImplementationOnce(() => {
526+
return {
527+
[Symbol.asyncIterator]: () => ({
528+
async next() {
529+
return { done: true }
530+
},
531+
}),
532+
}
533+
})
534+
535+
const messageGenerator = handlerWithModel.createMessage("system prompt", [])
536+
await messageGenerator.next()
537+
538+
expect(mockCreate).toHaveBeenCalledWith(
539+
expect.objectContaining({
540+
model: "glm-5.1",
541+
max_tokens: 100_000,
542+
}),
543+
)
544+
})
545+
506546
it("should enable thinking by default for GLM-4.7 (default reasoningEffort is medium)", async () => {
507547
const handlerWithModel = new ZAiHandler({
508548
apiModelId: "glm-4.7",

src/core/config/ProviderSettingsManager.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -567,11 +567,18 @@ export class ProviderSettingsManager {
567567
const supportsReasoningBudget =
568568
modelInfo.supportsReasoningBudget || modelInfo.requiredReasoningBudget
569569

570-
// If the model doesn't support reasoning budgets, remove the token fields
570+
// modelMaxThinkingTokens only applies to reasoning budgets, but modelMaxTokens
571+
// also caps output on models that expose a configurable max (e.g. GLM), so keep
572+
// it whenever the model supports either feature.
573+
const supportsMaxTokens = supportsReasoningBudget || modelInfo.supportsMaxTokens
574+
571575
if (!supportsReasoningBudget) {
572-
delete configs[name].modelMaxTokens
573576
delete configs[name].modelMaxThinkingTokens
574577
}
578+
579+
if (!supportsMaxTokens) {
580+
delete configs[name].modelMaxTokens
581+
}
575582
} catch (error) {
576583
// If we can't build the API handler or get model info, skip filtering
577584
// to avoid accidental data loss from incomplete configurations

src/core/config/__tests__/ProviderSettingsManager.spec.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,33 @@ import type { ProviderSettings } from "@roo-code/types"
66

77
import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager"
88

9+
// `export()` builds an API handler per profile to read model capabilities. Mock
10+
// buildApiHandler with the real @roo-code/types model definitions so the token-field
11+
// filtering is driven by real capability flags, and so this suite stays isolated from
12+
// sibling specs that also mock "../../../api" (avoids a cross-file mock leak under
13+
// Vitest's singleFork pool).
14+
vi.mock("../../../api", async () => {
15+
const types = await vi.importActual<typeof import("@roo-code/types")>("@roo-code/types")
16+
const zaiModels = { ...types.internationalZAiModels, ...types.mainlandZAiModels } as Record<string, unknown>
17+
const anthropicModels = types.anthropicModels as Record<string, unknown>
18+
const modelInfoFor = (config: { apiProvider?: string; apiModelId?: string }) => {
19+
const id = config?.apiModelId ?? ""
20+
switch (config?.apiProvider) {
21+
case "zai":
22+
return zaiModels[id] ?? {}
23+
case "anthropic":
24+
return anthropicModels[id] ?? {}
25+
default:
26+
return {}
27+
}
28+
}
29+
return {
30+
buildApiHandler: (config: any) => ({
31+
getModel: () => ({ id: config?.apiModelId ?? "", info: modelInfoFor(config) }),
32+
}),
33+
}
34+
})
35+
936
// Mock VSCode ExtensionContext
1037
const mockSecrets = {
1138
get: vi.fn(),
@@ -874,6 +901,52 @@ describe("ProviderSettingsManager", () => {
874901
expect(exported.apiConfigs.retired.modelMaxTokens).toBe(4096)
875902
expect(exported.apiConfigs.retired.modelMaxThinkingTokens).toBe(2048)
876903
})
904+
905+
it("should preserve modelMaxTokens for models that support a configurable max output (e.g. GLM)", async () => {
906+
const existingConfig: ProviderProfiles = {
907+
currentApiConfigName: "glm",
908+
apiConfigs: {
909+
glm: {
910+
id: "glm-id",
911+
apiProvider: "zai",
912+
apiModelId: "glm-5.1",
913+
modelMaxTokens: 8192,
914+
modelMaxThinkingTokens: 2048,
915+
},
916+
},
917+
}
918+
919+
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
920+
921+
const exported = await providerSettingsManager.export()
922+
923+
// GLM exposes a configurable max output (supportsMaxTokens) but no reasoning budget,
924+
// so modelMaxTokens must survive the export while modelMaxThinkingTokens is dropped.
925+
expect(exported.apiConfigs.glm.modelMaxTokens).toBe(8192)
926+
expect(exported.apiConfigs.glm.modelMaxThinkingTokens).toBeUndefined()
927+
})
928+
929+
it("should strip both token fields for models that support neither reasoning budgets nor a configurable max", async () => {
930+
const existingConfig: ProviderProfiles = {
931+
currentApiConfigName: "anthropic",
932+
apiConfigs: {
933+
anthropic: {
934+
id: "anthropic-id",
935+
apiProvider: "anthropic",
936+
apiModelId: "claude-3-5-haiku-20241022",
937+
modelMaxTokens: 8192,
938+
modelMaxThinkingTokens: 2048,
939+
},
940+
},
941+
}
942+
943+
mockSecrets.get.mockResolvedValue(JSON.stringify(existingConfig))
944+
945+
const exported = await providerSettingsManager.export()
946+
947+
expect(exported.apiConfigs.anthropic.modelMaxTokens).toBeUndefined()
948+
expect(exported.apiConfigs.anthropic.modelMaxThinkingTokens).toBeUndefined()
949+
})
877950
})
878951

879952
describe("ResetAllConfigs", () => {

src/shared/__tests__/api.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,41 @@ describe("getModelMaxOutputTokens", () => {
240240
})
241241
})
242242

243+
test("should honor the user's modelMaxTokens override for supportsMaxTokens models (e.g. Z.ai GLM)", () => {
244+
const model: ModelInfo = {
245+
contextWindow: 200_000,
246+
supportsPromptCache: false,
247+
supportsMaxTokens: true,
248+
maxTokens: 98_304, // model ceiling, well above the 20% clamp (40k)
249+
}
250+
251+
const settings: ProviderSettings = {
252+
apiProvider: "zai",
253+
modelMaxTokens: 64_000, // user override, above 20% of the context window (40k)
254+
}
255+
256+
const result = getModelMaxOutputTokens({ modelId: "glm-4.6", model, settings, format: "openai" })
257+
// Honored instead of clamped to 20% of the context window.
258+
expect(result).toBe(64_000)
259+
})
260+
261+
test("should cap the supportsMaxTokens override at the model's own maxTokens ceiling", () => {
262+
const model: ModelInfo = {
263+
contextWindow: 200_000,
264+
supportsPromptCache: false,
265+
supportsMaxTokens: true,
266+
maxTokens: 32_000,
267+
}
268+
269+
const settings: ProviderSettings = {
270+
apiProvider: "zai",
271+
modelMaxTokens: 999_999, // beyond the model ceiling
272+
}
273+
274+
const result = getModelMaxOutputTokens({ modelId: "glm-4.6", model, settings, format: "openai" })
275+
expect(result).toBe(32_000)
276+
})
277+
243278
test("should still apply 20% cap to non-GPT-5 models", () => {
244279
const model: ModelInfo = {
245280
contextWindow: 200_000,

src/shared/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,13 @@ export const getModelMaxOutputTokens = ({
132132
return ANTHROPIC_DEFAULT_MAX_TOKENS
133133
}
134134

135+
// Models that expose a configurable max-output slider (e.g. Z.ai GLM) honor the user's
136+
// explicit override instead of the default 20% context-window clamp, capped at the model's
137+
// own ceiling. This keeps the runtime budget consistent with the value sent to the provider.
138+
if (model.supportsMaxTokens && settings?.modelMaxTokens != null && settings.modelMaxTokens > 0) {
139+
return model.maxTokens ? Math.min(settings.modelMaxTokens, model.maxTokens) : settings.modelMaxTokens
140+
}
141+
135142
// If model has explicit maxTokens, clamp it to 20% of the context window
136143
// Exception: GPT-5 models should use their exact configured max output tokens
137144
if (model.maxTokens) {

0 commit comments

Comments
 (0)