Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions src/provider/reasoning.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
124 changes: 124 additions & 0 deletions src/provider/reasoning.ts
Original file line number Diff line number Diff line change
@@ -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<Record<Glm53Effort, number>> = {
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;
}
64 changes: 64 additions & 0 deletions src/translator/anthropic-to-openai.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
13 changes: 13 additions & 0 deletions src/translator/anthropic-to-openai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading