From 675298ba6d726352645172e76dcebb82fe262077 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Sat, 29 Aug 2026 01:34:28 +0800 Subject: [PATCH 1/2] Fix: carry reasoning effort through to GLM-5.3 on the OpenAI client path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On coding-plan the upstream is Anthropic-format, so an OpenAI client's request goes through translateRequestOpenAIToAnthropic. That function only special-cased reasoning_effort:"none"; every other value degraded to `{type:"enabled"}` with no budget and no output_config. A captured upstream body: client sent reasoning_effort:"high", model glm-5.3 -> upstream got {"model":"glm-5.3","thinking":{"type":"enabled"}} -> 1 character of thinking, where effort:"high" yields ~244 The Anthropic endpoint silently ignores reasoning_effort, so output_config.effort is the only channel that works there, and the upstream validates neither an illegal effort value nor a budget larger than max_tokens — normalizing has to happen here. New src/provider/reasoning.ts holds the contract in one place, taken from ZCode's catalog entry for glm-5.3: three levels with budgets 8000 / 16000 / 32000, defaultLevel "max". Effort normalization follows Z.AI's published table, which rounds up — "medium" maps to "high", not "low"; there is a named regression test for that. Also carries output_config.effort into reasoning_effort in the start-plan direction (Anthropic client, OpenAI upstream), where the channels are reversed. That carry-over is gated to the GLM-5.3 family: output_config is this family's extension, and forwarding it for every model would let a client reroute another model's reasoning through a channel it never opted into. Two default-value changes for the GLM-5.3 family only, both following ZCode: a request that omits reasoning_effort now gets effort "max" rather than the gateway's near-zero default, and one that omits max_tokens falls back to the model's catalog maxOutputTokens rather than the generic 4096. The second is load-bearing for the first — ZCode's clamp of `min(budget, maxOutputTokens - 1)` is written against the model ceiling, and applying it to a 4096 fallback left a 4095-token thinking budget with nothing for the answer. fitGlm53Budget now reserves 1024 tokens for the answer and floors a fractional max_tokens. Membership of the pinned catalog is a hint here, never a gate. Model matching is by id pattern, so a family member the catalog does not list (glm-5.3-flash today) still gets the effort channel; only the max_tokens fallback consults the catalog, and it degrades to the generic 4096 default rather than failing. Tests cover both. Net effect on a bare OpenAI request for glm-5.3: max_tokens 4096 and ~1 character of thinking becomes max_tokens 128_000 with a 32_000 thinking budget. That is ZCode parity, and it costs more. --- src/provider/reasoning.test.ts | 163 +++++++++++++++ src/provider/reasoning.ts | 124 +++++++++++ src/translator/anthropic-to-openai.test.ts | 64 ++++++ src/translator/anthropic-to-openai.ts | 13 ++ src/translator/openai-to-anthropic.test.ts | 228 +++++++++++++++++++++ src/translator/openai-to-anthropic.ts | 90 +++++++- src/translator/types.ts | 8 + 7 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 src/provider/reasoning.test.ts create mode 100644 src/provider/reasoning.ts create mode 100644 src/translator/anthropic-to-openai.test.ts diff --git a/src/provider/reasoning.test.ts b/src/provider/reasoning.test.ts new file mode 100644 index 0000000..94af7f6 --- /dev/null +++ b/src/provider/reasoning.test.ts @@ -0,0 +1,163 @@ +/** + * Tests for the GLM-5.3 reasoning-effort contract. + * @see src/provider/reasoning.ts + */ +import { describe, it, expect } from "bun:test"; +import { + isGlm53Model, + normalizeGlm53Effort, + buildGlm53Reasoning, + fitGlm53Budget, + GLM53_DEFAULT_EFFORT, + GLM53_THINKING_BUDGETS, + GLM53_MIN_THINKING_BUDGET, + GLM53_ANSWER_RESERVE, +} from "./reasoning.js"; + +describe("isGlm53Model", () => { + it("matches glm-5.3", () => { + expect(isGlm53Model("glm-5.3")).toBe(true); + }); + + it("matches GLM-5.3 case-insensitively", () => { + expect(isGlm53Model("GLM-5.3")).toBe(true); + }); + + it("matches glm-5.3-flash", () => { + expect(isGlm53Model("glm-5.3-flash")).toBe(true); + }); + + it("does not match glm-5.2", () => { + expect(isGlm53Model("glm-5.2")).toBe(false); + }); + + it("does not match glm-5.1", () => { + expect(isGlm53Model("glm-5.1")).toBe(false); + }); + + it("does not match glm-5", () => { + expect(isGlm53Model("glm-5")).toBe(false); + }); + + it("does not match glm-4.7", () => { + expect(isGlm53Model("glm-4.7")).toBe(false); + }); + + it("does not match undefined", () => { + expect(isGlm53Model(undefined)).toBe(false); + }); +}); + +describe("normalizeGlm53Effort", () => { + it("maps 'none' to 'low'", () => { + expect(normalizeGlm53Effort("none")).toBe("low"); + }); + + it("maps 'minimal' to 'low'", () => { + expect(normalizeGlm53Effort("minimal")).toBe("low"); + }); + + it("maps 'light' to 'low'", () => { + expect(normalizeGlm53Effort("light")).toBe("low"); + }); + + it("maps 'low' to 'low'", () => { + expect(normalizeGlm53Effort("low")).toBe("low"); + }); + + it("rounds 'medium' UP to 'high', not down to 'low' (regression: got this wrong once)", () => { + expect(normalizeGlm53Effort("medium")).toBe("high"); + }); + + it("maps 'high' to 'high'", () => { + expect(normalizeGlm53Effort("high")).toBe("high"); + }); + + it("maps 'xhigh' to 'max'", () => { + expect(normalizeGlm53Effort("xhigh")).toBe("max"); + }); + + it("maps 'max' to 'max'", () => { + expect(normalizeGlm53Effort("max")).toBe("max"); + }); + + it("maps 'ultra' to 'max'", () => { + expect(normalizeGlm53Effort("ultra")).toBe("max"); + }); + + it("defaults unrecognized values to the catalog default (max)", () => { + expect(normalizeGlm53Effort("bogus")).toBe(GLM53_DEFAULT_EFFORT); + }); + + it("defaults absent value to the catalog default (max)", () => { + expect(normalizeGlm53Effort(undefined)).toBe(GLM53_DEFAULT_EFFORT); + }); +}); + +describe("buildGlm53Reasoning", () => { + it("pairs low effort with its catalog budget", () => { + expect(buildGlm53Reasoning("low")).toEqual({ + thinking: { type: "enabled", budget_tokens: GLM53_THINKING_BUDGETS.low }, + output_config: { effort: "low" }, + }); + }); + + it("pairs high effort with its catalog budget", () => { + expect(buildGlm53Reasoning("high")).toEqual({ + thinking: { type: "enabled", budget_tokens: GLM53_THINKING_BUDGETS.high }, + output_config: { effort: "high" }, + }); + }); + + it("pairs max effort with its catalog budget", () => { + expect(buildGlm53Reasoning("max")).toEqual({ + thinking: { type: "enabled", budget_tokens: GLM53_THINKING_BUDGETS.max }, + output_config: { effort: "max" }, + }); + }); +}); + +describe("fitGlm53Budget", () => { + it("clamps the budget to max_tokens minus the answer reserve, not max_tokens - 1", () => { + // Regression: an earlier version clamped to `maxTokens - 1`, which left + // only a single token for the answer whenever max_tokens was small. + expect(fitGlm53Budget(32_000, 20_000)).toBe(20_000 - GLM53_ANSWER_RESERVE); + }); + + it("passes the budget through unchanged when it already fits with room to spare", () => { + expect(fitGlm53Budget(8_000, 128_000)).toBe(8_000); + }); + + it("returns undefined when the clamped budget falls below the 1024 floor", () => { + expect(fitGlm53Budget(8_000, 1_000)).toBeUndefined(); + }); + + it("returns exactly the floor budget unclamped when max_tokens leaves just enough room for floor + answer reserve", () => { + const maxTokens = GLM53_MIN_THINKING_BUDGET + GLM53_ANSWER_RESERVE; + expect(fitGlm53Budget(8_000, maxTokens)).toBe(GLM53_MIN_THINKING_BUDGET); + }); + + it("passes the budget through unchanged when maxTokens is not a finite number", () => { + expect(fitGlm53Budget(32_000, undefined)).toBe(32_000); + expect(fitGlm53Budget(32_000, Number.NaN)).toBe(32_000); + expect(fitGlm53Budget(32_000, "128000")).toBe(32_000); + }); + + it("regression: a small explicit max_tokens (e.g. the old generic 4096 default) no longer collapses the thinking budget to a single token, leaving GLM53_ANSWER_RESERVE for the answer", () => { + // Before this fix, fitGlm53Budget(32_000, 4096) returned 4095 — nearly + // the entire response allowance spent on thinking, leaving 1 token for + // the answer. It must now leave at least GLM53_ANSWER_RESERVE tokens. + const result = fitGlm53Budget(32_000, 4096); + expect(result).toBe(4096 - GLM53_ANSWER_RESERVE); + expect(4096 - (result ?? 0)).toBeGreaterThanOrEqual(GLM53_ANSWER_RESERVE); + }); + + it("client omits max_tokens on glm-5.3: given the model's real maxOutputTokens ceiling (128,000, not the generic 4096 fallback), the full effort-level budget survives untouched", () => { + // This is the scenario resolveDefaultMaxTokens() in openai-to-anthropic.ts + // exists for: when max_tokens is omitted, glm-5.3 now defaults to its + // catalog maxOutputTokens (128,000) instead of the generic 4096, so even + // the largest effort budget (max: 32,000) has ample room to survive the + // clamp in fitGlm53Budget unchanged. + expect(fitGlm53Budget(GLM53_THINKING_BUDGETS.max, 128_000)).toBe(GLM53_THINKING_BUDGETS.max); + }); +}); diff --git a/src/provider/reasoning.ts b/src/provider/reasoning.ts new file mode 100644 index 0000000..f09f610 --- /dev/null +++ b/src/provider/reasoning.ts @@ -0,0 +1,124 @@ +/** + * GLM-5.3 family reasoning-effort contract. + * + * The Anthropic upstream ignores the OpenAI `reasoning_effort` field entirely + * for this model family — `output_config.effort` is the only channel that + * actually changes how much the model thinks, and it must be paired with a + * matching `thinking.budget_tokens` (the effort label alone still produces + * near-zero thinking). Values below come from ZCode's own model catalog + * entry for `glm-5.3` (`defaultLevel: "max"`, three effort levels each + * setting both fields) plus live upstream verification. + */ + +/** The three legal `output_config.effort` levels for GLM-5.3 models. */ +export const GLM53_EFFORT_LEVELS = ["low", "high", "max"] as const; + +/** One of the three legal GLM-5.3 effort levels. */ +export type Glm53Effort = (typeof GLM53_EFFORT_LEVELS)[number]; + +/** ZCode catalog's `defaultLevel` for glm-5.3 — used when no effort is requested. */ +export const GLM53_DEFAULT_EFFORT: Glm53Effort = "max"; + +/** + * Thinking token budgets ZCode's catalog pairs with each effort level. + * Sending `output_config.effort` without a matching `thinking.budget_tokens` + * leaves the upstream at its own near-zero default. + */ +export const GLM53_THINKING_BUDGETS: Readonly> = { + low: 8_000, + high: 16_000, + max: 32_000, +}; + +/** + * Floor below which a thinking budget stops being useful — measured live + * against the upstream (a budget this small collapses back to near-zero + * thinking output). + */ +export const GLM53_MIN_THINKING_BUDGET = 1_024; + +/** + * Tokens reserved for the actual answer once the thinking budget is + * subtracted from `max_tokens` — see `fitGlm53Budget`. ZCode's own clamp + * (`Math.min(budgetTokens, maxOutputTokens - 1)`) is written against the + * *model's* maxOutputTokens ceiling, a large fixed number (128,000 for + * glm-5.3) where reserving a single token for the answer is harmless. + * Applying that same "-1" literally against a small per-request + * `max_tokens` is not — it leaves the response with essentially nothing to + * work with. 1,024 tokens (matching `GLM53_MIN_THINKING_BUDGET`'s own + * granularity) is a defensible floor: enough for a short but real answer, + * without meaningfully eating into a large thinking budget when + * `max_tokens` is generous. + */ +export const GLM53_ANSWER_RESERVE = 1_024; + +/** + * Match the GLM-5.3 model family, including `glm-5.3-flash`, case-insensitively + * (the upstream also accepts `GLM-5.3`). Deliberately excludes `glm-5`, + * `glm-5.1`, and `glm-5.2` — the negative lookahead rejects a trailing digit + * so `glm-5.30` (were it ever added) would not falsely match either. + */ +const GLM53_MODEL_PATTERN = /glm-5\.3(?![0-9])/i; + +/** True when `model` belongs to the GLM-5.3 family (`glm-5.3`, `glm-5.3-flash`, ...). */ +export function isGlm53Model(model: string | undefined): boolean { + if (!model) return false; + return GLM53_MODEL_PATTERN.test(model); +} + +/** + * Map an OpenAI `reasoning_effort` value onto the three GLM-5.3 effort + * levels, per Z.AI's official mapping table. Unrecognized or absent values + * fall back to the catalog default (`max`) rather than the OpenAI-side + * default (`medium`), since ZCode's own `defaultLevel` for this family is + * `max`. The mapping rounds UP, not to nearest — `medium` maps to `high`, + * not `low`. + */ +export function normalizeGlm53Effort(effort: string | undefined): Glm53Effort { + switch (effort) { + case "none": + case "minimal": + case "light": + case "low": + return "low"; + case "medium": + case "high": + return "high"; + case "xhigh": + case "max": + case "ultra": + return "max"; + default: + return GLM53_DEFAULT_EFFORT; + } +} + +/** Build the paired `thinking` + `output_config` fields for a GLM-5.3 effort level. */ +export function buildGlm53Reasoning(effort: Glm53Effort): { + thinking: { type: "enabled"; budget_tokens: number }; + output_config: { effort: Glm53Effort }; +} { + return { + thinking: { type: "enabled", budget_tokens: GLM53_THINKING_BUDGETS[effort] }, + output_config: { effort }, + }; +} + +/** + * Clamp a thinking budget to fit inside `max_tokens`, reserving + * `GLM53_ANSWER_RESERVE` tokens for the answer — ZCode's catalog spends the + * thinking budget out of the same token pool as the response, so a budget + * that eats the whole of `max_tokens` (or all but one token of it) would + * leave no meaningful room for output. Returns `undefined` when the clamped + * budget falls below `GLM53_MIN_THINKING_BUDGET` (the caller should then + * fall back to `{type:"enabled"}` with no explicit budget). Passes `budget` + * through unchanged when `maxTokens` isn't a finite number — the upstream + * doesn't validate this either, so there is nothing useful to clamp against. + */ +export function fitGlm53Budget(budget: number, maxTokens: unknown): number | undefined { + if (typeof maxTokens !== "number" || !Number.isFinite(maxTokens)) return budget; + // Floor first: JSON permits a fractional `max_tokens`, and a fractional + // `budget_tokens` is not a value the upstream should ever be handed. + const clamped = Math.min(budget, Math.floor(maxTokens) - GLM53_ANSWER_RESERVE); + return clamped >= GLM53_MIN_THINKING_BUDGET ? clamped : undefined; +} diff --git a/src/translator/anthropic-to-openai.test.ts b/src/translator/anthropic-to-openai.test.ts new file mode 100644 index 0000000..af5260a --- /dev/null +++ b/src/translator/anthropic-to-openai.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for the Anthropic → OpenAI request translator's `output_config` + * handling (start-plan direction: Anthropic client → OpenAI upstream). + * @see src/translator/anthropic-to-openai.ts + */ +import { describe, it, expect } from "bun:test"; +import { translateRequestAnthropicToOpenAI } from "./anthropic-to-openai.js"; +import type { AnthropicMessagesRequest } from "./types.js"; + +describe("translateRequestAnthropicToOpenAI: output_config.effort", () => { + it("carries output_config.effort into reasoning_effort on the OpenAI-format upstream body", () => { + const req: AnthropicMessagesRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + output_config: { effort: "high" }, + }; + + const result = translateRequestAnthropicToOpenAI(req); + + expect(result.reasoning_effort).toBe("high"); + }); + + it("omits reasoning_effort when output_config is absent", () => { + const req: AnthropicMessagesRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + }; + + const result = translateRequestAnthropicToOpenAI(req); + + expect(result.reasoning_effort).toBeUndefined(); + }); + + // `output_config` is a GLM-5.3 family extension. A client that sends it on + // any other model must not have that model's reasoning silently rerouted + // through a channel it never opted into. + it("does not carry output_config.effort over for a non-GLM-5.3 model", () => { + const req: AnthropicMessagesRequest = { + model: "glm-4.7", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + output_config: { effort: "high" }, + }; + + const result = translateRequestAnthropicToOpenAI(req); + + expect(result.reasoning_effort).toBeUndefined(); + }); + + it("omits reasoning_effort when output_config is present but effort is not set", () => { + const req: AnthropicMessagesRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 100, + output_config: {}, + }; + + const result = translateRequestAnthropicToOpenAI(req); + + expect(result.reasoning_effort).toBeUndefined(); + }); +}); diff --git a/src/translator/anthropic-to-openai.ts b/src/translator/anthropic-to-openai.ts index 5b8ed63..6eefa21 100644 --- a/src/translator/anthropic-to-openai.ts +++ b/src/translator/anthropic-to-openai.ts @@ -15,6 +15,7 @@ import type { AnthropicContentBlock, AnthropicUsage, } from "./types.js"; +import { isGlm53Model } from "../provider/reasoning.js"; /** Translate an Anthropic messages request into an OpenAI chat request. */ export function translateRequestAnthropicToOpenAI(req: AnthropicMessagesRequest): OpenAIChatRequest { @@ -48,6 +49,18 @@ export function translateRequestAnthropicToOpenAI(req: AnthropicMessagesRequest) result.thinking = req.thinking; } + // GLM-5.3+ Anthropic clients set reasoning effort via `output_config.effort` + // (the Anthropic upstream ignores `reasoning_effort`); the OpenAI-format + // upstream on start-plan reads `reasoning_effort` instead, so carry it over. + // + // Scoped to the GLM-5.3 family, which is the only one that defines + // `output_config`. Carrying it over unconditionally would let a client that + // sends `output_config` on any other model silently change that model's + // reasoning behaviour through a channel it never opted into. + if (isGlm53Model(req.model) && req.output_config?.effort) { + result.reasoning_effort = req.output_config.effort as OpenAIChatRequest["reasoning_effort"]; + } + if (req.tools?.length) { result.tools = req.tools.map((t) => ({ type: "function" as const, diff --git a/src/translator/openai-to-anthropic.test.ts b/src/translator/openai-to-anthropic.test.ts index 248d92e..0c329bc 100644 --- a/src/translator/openai-to-anthropic.test.ts +++ b/src/translator/openai-to-anthropic.test.ts @@ -466,6 +466,234 @@ describe("translateRequestOpenAIToAnthropic", () => { expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 1024 }); }); + + describe("GLM-5.3 reasoning-effort bug: reasoning_effort alone must produce output_config.effort AND a matching thinking.budget_tokens", () => { + // Before the fix, `translateThinking()` only special-cased + // `reasoning_effort:"none"`; every other value (e.g. "high") degraded to + // `{type:"enabled"}` with no budget_tokens and no output_config. Captured + // live upstream body: {"model":"glm-5.3","max_tokens":40000,"thinking":{"type":"enabled"}} + // — which produced 1 character of thinking instead of ~244. + + it("glm-5.3: reasoning_effort:'high' sets BOTH output_config.effort and thinking.budget_tokens", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "high", + max_tokens: 40_000, + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "high" }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 }); + }); + + it("glm-5.3-flash: reasoning_effort:'high' also sets BOTH fields (previously got no thinking field at all)", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3-flash", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "high", + max_tokens: 40_000, + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "high" }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 }); + }); + + it("glm-5.3: absent reasoning_effort defaults to 'max' (ZCode catalog defaultLevel), not near-zero", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 40_000, + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "max" }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 32_000 }); + }); + + it("glm-5.3: reasoning_effort:'medium' rounds UP to 'high' effort/budget, not down to 'low'", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "medium", + max_tokens: 40_000, + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "high" }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 }); + }); + + it("glm-5.3: reasoning_effort:'none' routes through the effort channel as 'low' rather than {type:'disabled'} (disabling is a no-op upstream)", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "none", + max_tokens: 40_000, + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "low" }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 8_000 }); + }); + + it("glm-5.3: explicit thinking.budget_tokens is respected over the effort-level default budget, but output_config.effort is still attached", () => { + const req = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "high", + max_tokens: 40_000, + thinking: { type: "enabled", budget_tokens: 5000 }, + } as OpenAIChatRequest; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "high" }); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 5000 }); + }); + + // A JSON body may carry a fractional budget. Testing `> 0` before flooring + // lets 0.5 through as a floored 0, which `fitGlm53Budget` passes straight + // out again whenever max_tokens is not a finite number. + it("glm-5.3: a sub-1 fractional explicit budget is ignored in favour of the effort default, never sent as 0", () => { + const req = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "low", + max_tokens: 40_000, + thinking: { type: "enabled", budget_tokens: 0.5 }, + } as OpenAIChatRequest; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 8_000 }); + }); + + it("glm-5.3: a fractional explicit budget above 1 is floored, not rounded", () => { + const req = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "high", + max_tokens: 40_000, + thinking: { type: "enabled", budget_tokens: 5000.7 }, + } as OpenAIChatRequest; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 5000 }); + }); + + it("glm-5.3: thinking budget is clamped to fit under max_tokens, reserving headroom for the answer", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "max", + max_tokens: 20_000, + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.max_tokens).toBe(20_000); + // Not 19_999 (max_tokens - 1) — that would leave only 1 token for the + // answer. 1_024 tokens (GLM53_ANSWER_RESERVE) are reserved instead. + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 18_976 }); + expect(result.output_config).toEqual({ effort: "max" }); + }); + + it("client omits max_tokens on glm-5.3: defaults to the model's catalog maxOutputTokens (128,000), not the generic 4096, so the max-effort thinking budget survives untouched", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "max", + }; + + const result = translateRequestOpenAIToAnthropic(req); + + // Before this fix, the generic DEFAULT_MAX_TOKENS (4096) fallback meant + // fitGlm53Budget(32_000, 4096) clamped the thinking budget down to + // 4095 — nearly the entire response allowance, leaving ~1 token for + // the answer. A model-aware default fixes that at the source. + expect(result.max_tokens).toBe(128_000); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 32_000 }); + expect(result.output_config).toEqual({ effort: "max" }); + }); + + // The effort channel is keyed off the model id, not off catalog membership, + // so it keeps working for a family member the pinned catalog does not list + // (glm-5.3-flash is one today). Only the max_tokens fallback consults the + // catalog, and it degrades to the generic default rather than failing. + it("glm-5.3-flash (absent from the pinned catalog) still gets the effort channel, with the generic max_tokens fallback", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3-flash", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "max", + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "max" }); + expect(result.max_tokens).toBe(4096); + // max effort's 32_000 budget is clamped under the small fallback. + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 4096 - 1024 }); + }); + + it("a GLM-5.3-family id absent from the catalog falls back to the generic 4096 default, not a crash", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3-unlisted-variant", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "low", + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.max_tokens).toBe(4096); + // low effort's 8_000 budget is clamped down under the small fallback. + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 4096 - 1024 }); + }); + + it("non-GLM-5.3 models keep the generic 4096 default max_tokens (scoped change, not global)", () => { + const req: OpenAIChatRequest = { + model: "glm-4.7", + messages: [{ role: "user", content: "Hi" }], + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.max_tokens).toBe(4096); + }); + + it("glm-5.3: explicit thinking:{type:'disabled'} is forwarded as-is, not overridden to an effort level", () => { + const req = { + model: "glm-5.3", + messages: [{ role: "user", content: "Hi" }], + thinking: { type: "disabled" }, + } as OpenAIChatRequest; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.thinking).toEqual({ type: "disabled" }); + expect(result.output_config).toBeUndefined(); + }); + + it("glm-4.7 (non-GLM-5.3 reasoning model) is unchanged: no output_config, plain {type:'enabled'} thinking", () => { + const req: OpenAIChatRequest = { + model: "glm-4.7", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "high", + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.thinking).toEqual({ type: "enabled" }); + expect(result.output_config).toBeUndefined(); + }); + }); }); describe("anthropicUsageToOpenAI", () => { diff --git a/src/translator/openai-to-anthropic.ts b/src/translator/openai-to-anthropic.ts index d191f24..5878b4b 100644 --- a/src/translator/openai-to-anthropic.ts +++ b/src/translator/openai-to-anthropic.ts @@ -15,8 +15,15 @@ import type { AnthropicToolDefinition, AnthropicThinkingConfig, AnthropicUsage, + AnthropicOutputConfig, } from "./types.js"; import { MODELS } from "../provider/models.js"; +import { + isGlm53Model, + normalizeGlm53Effort, + buildGlm53Reasoning, + fitGlm53Budget, +} from "../provider/reasoning.js"; /** Default max_tokens if the OpenAI request doesn't specify one. */ const DEFAULT_MAX_TOKENS = 4096; @@ -35,7 +42,7 @@ export function translateRequestOpenAIToAnthropic(req: OpenAIChatRequest): Anthr const result: AnthropicMessagesRequest = { model: req.model, messages: anthropicMessages, - max_tokens: req.max_tokens ?? DEFAULT_MAX_TOKENS, + max_tokens: req.max_tokens ?? resolveDefaultMaxTokens(req.model), }; if (system) result.system = system; @@ -43,8 +50,14 @@ export function translateRequestOpenAIToAnthropic(req: OpenAIChatRequest): Anthr if (req.top_p !== undefined) result.top_p = req.top_p; if (req.stream !== undefined) result.stream = req.stream; if (req.stop) result.stop_sequences = Array.isArray(req.stop) ? req.stop : [req.stop]; - const thinking = translateThinking(req); - if (thinking) result.thinking = thinking; + if (isGlm53Model(req.model)) { + const { thinking, output_config } = translateGlm53Reasoning(req, result.max_tokens); + result.thinking = thinking; + if (output_config) result.output_config = output_config; + } else { + const thinking = translateThinking(req); + if (thinking) result.thinking = thinking; + } if (req.tools?.length && req.tool_choice !== "none") { result.tools = req.tools.map(translateToolOpenAIToAnthropic); } @@ -82,6 +95,77 @@ function isReasoningModel(model: string): boolean { return MODELS.some((m) => m.id === model && m.reasoning === true); } +/** + * Resolve the max_tokens fallback when the OpenAI client omits it. + * + * The generic `DEFAULT_MAX_TOKENS` (4096) is too small to coexist with the + * GLM-5.3 effort-based thinking budgets `translateGlm53Reasoning` attaches + * below (up to 32,000) — `fitGlm53Budget` would clamp nearly the entire + * allowance into thinking, leaving almost nothing for the answer. ZCode's own + * clamp is written against the *model's* maxOutputTokens ceiling (128,000 for + * glm-5.3), not a generic request-level fallback, so for this family look up + * that ceiling in the catalog instead. Falls back to the generic default if + * the model id isn't in the catalog. Every other model's default is + * untouched — this is deliberately scoped to GLM-5.3 only. + */ +function resolveDefaultMaxTokens(model: string): number { + if (!isGlm53Model(model)) return DEFAULT_MAX_TOKENS; + const catalogEntry = MODELS.find((m) => m.id === model); + return catalogEntry?.maxOutputTokens ?? DEFAULT_MAX_TOKENS; +} + +/** + * Build the `thinking` + `output_config` pair for a GLM-5.3 family request. + * + * `output_config.effort` is the only channel the Anthropic upstream honors + * for this family — bare `reasoning_effort` is silently ignored — so every + * GLM-5.3 request gets an explicit effort level, defaulting to ZCode's + * catalog default (`max`) rather than falling through to a near-zero + * upstream default. `reasoning_effort:"none"` maps to `"low"` here (via + * `normalizeGlm53Effort`) instead of `{type:"disabled"}`: disabling does not + * actually work for plain glm-5.3 (54 chars of thinking still came back in + * live testing), so routing it through the effort channel is the closer + * approximation across the whole family. + * + * An explicit `req.thinking:{type:"disabled"}` is still forwarded as-is + * (rather than overridden to an effort level) since it does work for + * glm-5.3-flash, and is the closest available signal for plain glm-5.3. + * An explicit `req.thinking` budget is respected over the effort-level + * default budget, but `output_config.effort` is still attached — without it + * the upstream runs at its own near-zero default regardless of budget. + */ +function translateGlm53Reasoning( + req: OpenAIChatRequest, + maxTokens: number, +): { thinking: AnthropicThinkingConfig; output_config?: AnthropicOutputConfig } { + const explicit = req.thinking; + if (explicit && typeof explicit === "object" && explicit.type === "disabled") { + return { thinking: { type: "disabled" } }; + } + + const effort = normalizeGlm53Effort(req.reasoning_effort); + const base = buildGlm53Reasoning(effort); + + let budget: number = base.thinking.budget_tokens; + if (explicit && typeof explicit === "object" && (explicit.type === "enabled" || explicit.type === "adaptive")) { + const explicitBudget = explicit.budget_tokens ?? explicit.budgetTokens; + if (typeof explicitBudget === "number" && Number.isFinite(explicitBudget)) { + // Floor before the positivity test, not after: JSON permits a fractional + // budget, and a value like 0.5 passes `> 0` yet floors to 0 — which then + // survives `fitGlm53Budget` untouched whenever `max_tokens` is not a + // finite number, handing the upstream `budget_tokens: 0`. + const floored = Math.floor(explicitBudget); + if (floored > 0) budget = floored; + } + } + + const fitted = fitGlm53Budget(budget, maxTokens); + return { + thinking: fitted !== undefined ? { type: "enabled", budget_tokens: fitted } : { type: "enabled" }, + output_config: base.output_config, + }; +} + function translateToolChoice( choice: "none" | "auto" | "required" | { type: "function"; function: { name: string | undefined } }, ): { type: "auto" | "any" | "tool"; name?: string } | undefined { diff --git a/src/translator/types.ts b/src/translator/types.ts index c665658..86fed84 100644 --- a/src/translator/types.ts +++ b/src/translator/types.ts @@ -198,6 +198,14 @@ export interface AnthropicMessagesRequest { tools?: AnthropicToolDefinition[]; tool_choice?: { type: "auto" | "any" | "tool"; name?: string }; thinking?: AnthropicThinkingConfig; + /** GLM-5.3+ reasoning-effort channel; the Anthropic upstream ignores `reasoning_effort`. */ + output_config?: AnthropicOutputConfig; +} + +/** GLM-5.3+ reasoning-effort control (Anthropic upstream extension). */ +export interface AnthropicOutputConfig { + effort?: string; + task_budget?: { type: string; [k: string]: unknown }; } /** Anthropic thinking/reasoning control. */ From 4d0be203ec53b60cf2cddb4ab349bf0f4e2af021 Mon Sep 17 00:00:00 2001 From: TriDefender <173548745+TriDefender@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:56:30 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(translator):=20unlist=20the=20catalog-?= =?UTF-8?q?fallback=20fixture=20=E2=80=94=20master=20now=20pins=20glm-5.3-?= =?UTF-8?q?flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge of master (1646a4c 'feat(models): advertise glm-5.3-flash') invalidated the premise that glm-5.3-flash is absent from the pinned catalog: the fixture now resolves the model-aware default (128,000) instead of the generic 4096. - Point the unlisted-family fixture at a genuinely unlisted id (glm-5.3-air), preserving the original intent: the effort channel is keyed off the model id, not catalog membership. - Pin the new catalog reality: glm-5.3-flash gets the same model-aware default as glm-5.3 (max_tokens 128,000, max-effort budget 32,000). --- src/translator/openai-to-anthropic.test.ts | 23 ++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/translator/openai-to-anthropic.test.ts b/src/translator/openai-to-anthropic.test.ts index 0c329bc..073c239 100644 --- a/src/translator/openai-to-anthropic.test.ts +++ b/src/translator/openai-to-anthropic.test.ts @@ -626,11 +626,12 @@ describe("translateRequestOpenAIToAnthropic", () => { // The effort channel is keyed off the model id, not off catalog membership, // so it keeps working for a family member the pinned catalog does not list - // (glm-5.3-flash is one today). Only the max_tokens fallback consults the - // catalog, and it degrades to the generic default rather than failing. - it("glm-5.3-flash (absent from the pinned catalog) still gets the effort channel, with the generic max_tokens fallback", () => { + // (glm-5.3-flash used to be one until master pinned it). Only the + // max_tokens fallback consults the catalog, and it degrades to the generic + // default rather than failing. + it("an unlisted GLM-5.3-family id still gets the effort channel, with the generic max_tokens fallback", () => { const req: OpenAIChatRequest = { - model: "glm-5.3-flash", + model: "glm-5.3-air", messages: [{ role: "user", content: "Hi" }], reasoning_effort: "max", }; @@ -643,6 +644,20 @@ describe("translateRequestOpenAIToAnthropic", () => { expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 4096 - 1024 }); }); + it("glm-5.3-flash (pinned since master advertised it) gets the same model-aware default as glm-5.3", () => { + const req: OpenAIChatRequest = { + model: "glm-5.3-flash", + messages: [{ role: "user", content: "Hi" }], + reasoning_effort: "max", + }; + + const result = translateRequestOpenAIToAnthropic(req); + + expect(result.output_config).toEqual({ effort: "max" }); + expect(result.max_tokens).toBe(128_000); + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 32_000 }); + }); + it("a GLM-5.3-family id absent from the catalog falls back to the generic 4096 default, not a crash", () => { const req: OpenAIChatRequest = { model: "glm-5.3-unlisted-variant",