Skip to content

Commit 9bd5fbd

Browse files
feat(zai): expose configurable max output tokens for GLM models (#161)
Z.ai GLM models (glm-5.1, glm-5-turbo) default to a 20% output clamp and the runtime already honors an explicit modelMaxTokens, but the settings UI never surfaced a control for it — those models only exposed reasoning effort. Adds an optional supportsMaxTokens capability flag (set on the GLM models), and reuses the ThinkingBudget settings component to render a max-output-tokens slider gated on supportsMaxTokens && !supportsReasoningBudget. The slider defaults to the existing output clamp when unset, so behavior is unchanged until the user edits it; an explicit value persists as modelMaxTokens. Closes #161
1 parent b5c5e21 commit 9bd5fbd

5 files changed

Lines changed: 239 additions & 39 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",

webview-ui/src/components/settings/ThinkingBudget.tsx

Lines changed: 81 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS,
4747
DEFAULT_HYBRID_REASONING_MODEL_THINKING_TOKENS,
4848
GEMINI_25_PRO_MIN_THINKING_TOKENS,
49+
getModelMaxOutputTokens,
4950
} from "@roo/api"
5051

5152
import { useAppTranslation } from "@src/i18n/TranslationContext"
@@ -75,6 +76,10 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
7576
const isReasoningBudgetSupported = !!modelInfo && modelInfo.supportsReasoningBudget
7677
const isReasoningBudgetRequired = !!modelInfo && modelInfo.requiredReasoningBudget
7778
const isReasoningEffortSupported = !!modelInfo && modelInfo.supportsReasoningEffort
79+
// Models that advertise a user-configurable max output budget (e.g. Z.ai GLM) but do not
80+
// use the reasoning-budget slider. The reasoning-budget branch already renders its own
81+
// max-tokens control, so only surface this standalone slider when that branch is inactive.
82+
const isMaxTokensConfigurable = !!modelInfo && modelInfo.supportsMaxTokens && !isReasoningBudgetSupported
7883

7984
// Build available reasoning efforts list from capability
8085
const supports = modelInfo?.supportsReasoningEffort
@@ -160,10 +165,40 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
160165
}
161166
}, [isReasoningBudgetSupported, customMaxThinkingTokens, modelMaxThinkingTokens, setApiConfigurationField])
162167

168+
// Default max output budget for models that expose a standalone max-tokens slider.
169+
// When the user hasn't set an explicit `modelMaxTokens`, fall back to the same value
170+
// the runtime would use (the default output clamp) so behavior is unchanged.
171+
const defaultMaxOutputTokens =
172+
(isMaxTokensConfigurable && selectedModelId && modelInfo
173+
? getModelMaxOutputTokens({ modelId: selectedModelId, model: modelInfo, settings: apiConfiguration })
174+
: undefined) ??
175+
modelInfo?.maxTokens ??
176+
DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS
177+
const standaloneMaxOutputTokens = apiConfiguration.modelMaxTokens || defaultMaxOutputTokens
178+
163179
if (!modelInfo) {
164180
return null
165181
}
166182

183+
// Standalone max output tokens slider for models that advertise `supportsMaxTokens`
184+
// (e.g. Z.ai GLM) but do not surface the reasoning-budget control.
185+
const maxOutputTokensControl =
186+
isMaxTokensConfigurable && modelInfo.maxTokens ? (
187+
<div className="flex flex-col gap-1" data-testid="max-output-tokens">
188+
<div className="font-medium">{t("settings:thinkingBudget.maxTokens")}</div>
189+
<div className="flex items-center gap-1">
190+
<Slider
191+
min={1024}
192+
max={modelInfo.maxTokens}
193+
step={1024}
194+
value={[standaloneMaxOutputTokens]}
195+
onValueChange={([value]) => setApiConfigurationField("modelMaxTokens", value)}
196+
/>
197+
<div className="w-12 text-sm text-center">{standaloneMaxOutputTokens}</div>
198+
</div>
199+
</div>
200+
) : null
201+
167202
// Models with supportsReasoningBinary (binary reasoning) show a simple on/off toggle
168203
if (isReasoningSupported) {
169204
return (
@@ -228,44 +263,51 @@ export const ThinkingBudget = ({ apiConfiguration, setApiConfigurationField, mod
228263
)}
229264
</>
230265
) : isReasoningEffortSupported ? (
231-
<div className="flex flex-col gap-1" data-testid="reasoning-effort">
232-
<div className="flex justify-between items-center">
233-
<label className="block font-medium mb-1">{t("settings:providers.reasoningEffort.label")}</label>
234-
</div>
235-
<Select
236-
value={currentReasoningEffort}
237-
onValueChange={(value: ReasoningEffortOption) => {
238-
// "disable" turns off reasoning entirely; "none" is a valid reasoning level
239-
if (value === "disable") {
240-
setApiConfigurationField("enableReasoningEffort", false)
241-
setApiConfigurationField("reasoningEffort", "disable")
242-
} else {
243-
// "none", "minimal", "low", "medium", "high" all enable reasoning
244-
setApiConfigurationField("enableReasoningEffort", true)
245-
setApiConfigurationField("reasoningEffort", value as ReasoningEffortWithMinimal)
246-
}
247-
}}>
248-
<SelectTrigger className="w-full">
249-
<SelectValue
250-
placeholder={
251-
currentReasoningEffort
252-
? currentReasoningEffort === "none" || currentReasoningEffort === "disable"
253-
? t("settings:providers.reasoningEffort.none")
254-
: t(`settings:providers.reasoningEffort.${currentReasoningEffort}`)
255-
: t("settings:common.select")
266+
<>
267+
{maxOutputTokensControl}
268+
<div className="flex flex-col gap-1" data-testid="reasoning-effort">
269+
<div className="flex justify-between items-center">
270+
<label className="block font-medium mb-1">
271+
{t("settings:providers.reasoningEffort.label")}
272+
</label>
273+
</div>
274+
<Select
275+
value={currentReasoningEffort}
276+
onValueChange={(value: ReasoningEffortOption) => {
277+
// "disable" turns off reasoning entirely; "none" is a valid reasoning level
278+
if (value === "disable") {
279+
setApiConfigurationField("enableReasoningEffort", false)
280+
setApiConfigurationField("reasoningEffort", "disable")
281+
} else {
282+
// "none", "minimal", "low", "medium", "high" all enable reasoning
283+
setApiConfigurationField("enableReasoningEffort", true)
284+
setApiConfigurationField("reasoningEffort", value as ReasoningEffortWithMinimal)
256285
}
257-
/>
258-
</SelectTrigger>
259-
<SelectContent>
260-
{availableOptions.map((value) => (
261-
<SelectItem key={value} value={value}>
262-
{value === "none" || value === "disable"
263-
? t("settings:providers.reasoningEffort.none")
264-
: t(`settings:providers.reasoningEffort.${value}`)}
265-
</SelectItem>
266-
))}
267-
</SelectContent>
268-
</Select>
269-
</div>
270-
) : null
286+
}}>
287+
<SelectTrigger className="w-full">
288+
<SelectValue
289+
placeholder={
290+
currentReasoningEffort
291+
? currentReasoningEffort === "none" || currentReasoningEffort === "disable"
292+
? t("settings:providers.reasoningEffort.none")
293+
: t(`settings:providers.reasoningEffort.${currentReasoningEffort}`)
294+
: t("settings:common.select")
295+
}
296+
/>
297+
</SelectTrigger>
298+
<SelectContent>
299+
{availableOptions.map((value) => (
300+
<SelectItem key={value} value={value}>
301+
{value === "none" || value === "disable"
302+
? t("settings:providers.reasoningEffort.none")
303+
: t(`settings:providers.reasoningEffort.${value}`)}
304+
</SelectItem>
305+
))}
306+
</SelectContent>
307+
</Select>
308+
</div>
309+
</>
310+
) : (
311+
maxOutputTokensControl
312+
)
271313
}

webview-ui/src/components/settings/__tests__/ThinkingBudget.spec.tsx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,114 @@ describe("ThinkingBudget", () => {
305305
expect(screen.getByTestId("select-item-high")).toBeInTheDocument()
306306
})
307307
})
308+
309+
describe("configurable max output tokens (supportsMaxTokens)", () => {
310+
// Mirrors Z.ai GLM models: max output budget plus a reasoning-effort dropdown,
311+
// but no reasoning-budget control.
312+
const glmModelInfo: ModelInfo = {
313+
supportsMaxTokens: true,
314+
supportsReasoningEffort: ["disable", "medium"],
315+
maxTokens: 131072,
316+
contextWindow: 200000,
317+
supportsPromptCache: true,
318+
}
319+
320+
const glmApiConfiguration = { apiProvider: "zai", apiModelId: "glm-5.1" }
321+
322+
it("should render the max output tokens slider alongside the reasoning effort dropdown", () => {
323+
render(
324+
<ThinkingBudget
325+
{...defaultProps}
326+
apiConfiguration={glmApiConfiguration}
327+
modelInfo={glmModelInfo}
328+
/>,
329+
)
330+
331+
expect(screen.getByTestId("max-output-tokens")).toBeInTheDocument()
332+
expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument()
333+
})
334+
335+
it("should default the slider to the 20% clamp when modelMaxTokens is unset", () => {
336+
render(
337+
<ThinkingBudget
338+
{...defaultProps}
339+
apiConfiguration={glmApiConfiguration}
340+
modelInfo={glmModelInfo}
341+
/>,
342+
)
343+
344+
// 20% of 200000 = 40000 (the runtime clamp), since maxTokens (131072) exceeds it.
345+
const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")!
346+
expect(slider).toHaveValue("40000")
347+
})
348+
349+
it("should reflect an explicit modelMaxTokens override on the slider", () => {
350+
render(
351+
<ThinkingBudget
352+
{...defaultProps}
353+
apiConfiguration={{ ...glmApiConfiguration, modelMaxTokens: 100000 }}
354+
modelInfo={glmModelInfo}
355+
/>,
356+
)
357+
358+
const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")!
359+
expect(slider).toHaveValue("100000")
360+
})
361+
362+
it("should NOT persist modelMaxTokens on initial render (no user action)", () => {
363+
const setApiConfigurationField = vi.fn()
364+
render(
365+
<ThinkingBudget
366+
{...defaultProps}
367+
setApiConfigurationField={setApiConfigurationField}
368+
apiConfiguration={glmApiConfiguration}
369+
modelInfo={glmModelInfo}
370+
/>,
371+
)
372+
373+
// Initialization must not write the default clamp back to settings.
374+
expect(setApiConfigurationField).not.toHaveBeenCalledWith("modelMaxTokens", expect.anything())
375+
expect(setApiConfigurationField).not.toHaveBeenCalledWith(
376+
"modelMaxTokens",
377+
expect.anything(),
378+
expect.anything(),
379+
)
380+
})
381+
382+
it("should persist modelMaxTokens as a user action when the slider changes", () => {
383+
const setApiConfigurationField = vi.fn()
384+
render(
385+
<ThinkingBudget
386+
{...defaultProps}
387+
setApiConfigurationField={setApiConfigurationField}
388+
apiConfiguration={glmApiConfiguration}
389+
modelInfo={glmModelInfo}
390+
/>,
391+
)
392+
393+
const slider = screen.getByTestId("max-output-tokens").querySelector("input[type='range']")!
394+
fireEvent.change(slider, { target: { value: "65536" } })
395+
396+
// A real user edit persists modelMaxTokens without the isUserAction=false flag.
397+
expect(setApiConfigurationField).toHaveBeenCalledWith("modelMaxTokens", 65536)
398+
})
399+
400+
it("should not render the standalone slider when supportsMaxTokens is absent", () => {
401+
render(
402+
<ThinkingBudget
403+
{...defaultProps}
404+
apiConfiguration={glmApiConfiguration}
405+
modelInfo={{
406+
supportsReasoningEffort: ["disable", "medium"],
407+
maxTokens: 131072,
408+
contextWindow: 200000,
409+
supportsPromptCache: true,
410+
}}
411+
/>,
412+
)
413+
414+
expect(screen.queryByTestId("max-output-tokens")).not.toBeInTheDocument()
415+
expect(screen.getByTestId("reasoning-effort")).toBeInTheDocument()
416+
})
417+
})
308418
})

0 commit comments

Comments
 (0)