From b3017e4b8e69232773c8ec0325ea0a1633ac993a Mon Sep 17 00:00:00 2001 From: phuongddx <95doanphuong@gmail.com> Date: Fri, 14 Aug 2026 14:52:00 +0700 Subject: [PATCH] feat(sse): add GLM-5.3 models and effort tiers GLM-5.3 (released 2026-08-14) ships one upstream model id with effort as a reasoning_effort request param (low|high|max, default max); thinking can no longer be disabled. Adds glm-5.3/glm-5.3-high/glm-5.3-low to GLM_SHARED_MODELS (glm/glm-cn/glmt) and glm-5.3 base to the zai catalog. - GlmExecutor: parseGlm52Effort generalized to parseGlmEffortTier; 5.3 tiers stay on the OpenAI coding transport and inject reasoning_effort + thinking.type=enabled; 5.2 tiers keep the Anthropic effort beta path - modelSpecs + GLM_PRICING entries for the three ids - default context window (1M) and pricing mirrored from GLM-5.2 (same base model) until Z.ai publishes official 5.3 specs Extracted from #10358 (unrelated .planning/codebase/ scaffolding dump dropped). --- open-sse/config/glmProvider.ts | 29 +++ .../config/providers/registry/zai/index.ts | 14 +- open-sse/executors/glm.ts | 66 +++++-- src/shared/constants/modelSpecs.ts | 33 +++- src/shared/constants/pricing/shared-tiers.ts | 24 +++ .../glm-5.3-catalog-and-effort-tiers.test.ts | 180 ++++++++++++++++++ tests/unit/zai-catalog-glm52.test.ts | 2 +- 7 files changed, 326 insertions(+), 22 deletions(-) create mode 100644 tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index f8acfd4031..9c1580e7ae 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -18,6 +18,35 @@ export const GLM_ANTHROPIC_DEFAULT_BASE_URLS = Object.freeze({ }); export const GLM_SHARED_MODELS = Object.freeze([ + { + // GLM-5.3 (2026-08-14): one upstream id; effort is the reasoning_effort + // param (low|high|max, default max) — the -high/-low entries below are + // OmniRoute aliases resolved by GlmExecutor::parseGlmEffortTier. + // Default context window not yet published by Z.ai; 1M mirrored from + // GLM-5.2 (same base model). https://z.ai/blog/glm-5.3 + id: "glm-5.3", + name: "GLM 5.3", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "glm-5.3-high", + name: "GLM 5.3 High", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + }, + { + id: "glm-5.3-low", + name: "GLM 5.3 Low", + contextLength: 1000000, + maxOutputTokens: 131072, + toolCalling: true, + supportsReasoning: true, + }, { id: "glm-5.2", name: "GLM 5.2", diff --git a/open-sse/config/providers/registry/zai/index.ts b/open-sse/config/providers/registry/zai/index.ts index e126aee21f..1141ea8dc3 100644 --- a/open-sse/config/providers/registry/zai/index.ts +++ b/open-sse/config/providers/registry/zai/index.ts @@ -11,13 +11,15 @@ export const zaiProvider: RegistryEntry = { authType: "apikey", authHeader: "x-api-key", headers: getAnthropicCompatHeaders(), - // Real upstream model IDs only. The effort tiers (glm-5.2-high / glm-5.2-max) - // are intentionally NOT listed here: they are OmniRoute aliases resolved by the - // GlmExecutor (parseGlm52Effort → base "glm-5.2" + effort field). This provider - // uses the DefaultExecutor, which sends the model ID verbatim, so the aliases - // would reach z.ai's Anthropic endpoint as unknown IDs. Use the `glm` provider - // for effort tiers. Vision models are likewise omitted (handled elsewhere). + // Real upstream model IDs only. The effort tiers (glm-5.2-high/-max, + // glm-5.3-high/-low) are intentionally NOT listed here: they are OmniRoute + // aliases resolved by the GlmExecutor (parseGlmEffortTier → base model + + // effort selector). This provider uses the DefaultExecutor, which sends the + // model ID verbatim, so the aliases would reach z.ai's Anthropic endpoint as + // unknown IDs. Use the `glm` provider for effort tiers. Vision models are + // likewise omitted (handled elsewhere). models: [ + { id: "glm-5.3", name: "GLM 5.3" }, { id: "glm-5.2", name: "GLM 5.2" }, { id: "glm-5.1", name: "GLM 5.1" }, { id: "glm-5", name: "GLM 5" }, diff --git a/open-sse/executors/glm.ts b/open-sse/executors/glm.ts index 945dbe82cb..98e2112c70 100644 --- a/open-sse/executors/glm.ts +++ b/open-sse/executors/glm.ts @@ -52,17 +52,41 @@ function getEffectiveKey(credentials: ProviderCredentials): string { return credentials.apiKey || credentials.accessToken || ""; } +export type GlmEffortLevel = "low" | "high" | "max"; + +type GlmEffortTier = { + baseModel: string; + effort: GlmEffortLevel; + /** Transport where the upstream honors the effort selector for this family. */ + transport: GlmTransport; +}; + /** - * GLM-5.2 effort tiers route exclusively through the Anthropic transport, - * where Zhipu maps Claude Code effort selectors (high/max) to reasoning - * intensity. The base model ID sent upstream is always "glm-5.2". + * GLM-5.2 effort tiers (glm-5.2-high/-max) route exclusively through the + * Anthropic transport, where Zhipu maps Claude Code effort selectors (high/max) + * to reasoning intensity. The base model ID sent upstream is always "glm-5.2". + * + * GLM-5.3 replaced tier endpoints with a documented `reasoning_effort` request + * parameter (low|high|max, default max) on the coding chat/completions endpoint, + * so its tiers stay on the OpenAI transport and inject `reasoning_effort` + + * `thinking.type=enabled` (5.3 no longer accepts thinking disabled). * * https://docs.z.ai/devpack/latest-model + * https://z.ai/blog/glm-5.3 */ -function parseGlm52Effort(model: string): { baseModel: string; effort: "high" | "max" } | null { - if (model === "glm-5.2-high") return { baseModel: "glm-5.2", effort: "high" }; - if (model === "glm-5.2-max") return { baseModel: "glm-5.2", effort: "max" }; - return null; +function parseGlmEffortTier(model: string): GlmEffortTier | null { + switch (model) { + case "glm-5.2-high": + return { baseModel: "glm-5.2", effort: "high", transport: "anthropic" }; + case "glm-5.2-max": + return { baseModel: "glm-5.2", effort: "max", transport: "anthropic" }; + case "glm-5.3-high": + return { baseModel: "glm-5.3", effort: "high", transport: "openai" }; + case "glm-5.3-low": + return { baseModel: "glm-5.3", effort: "low", transport: "openai" }; + default: + return null; + } } /** @@ -278,7 +302,7 @@ export class GlmExecutor extends DefaultExecutor { credentials: ProviderCredentials, transport: GlmTransport ) { - const effortTier = parseGlm52Effort(model); + const effortTier = parseGlmEffortTier(model); const effectiveModel = effortTier ? effortTier.baseModel : model; const transformed = this.transformRequest(effectiveModel, body, stream, credentials); @@ -313,6 +337,14 @@ export class GlmExecutor extends DefaultExecutor { } if (transport === "openai") { + // GLM-5.3 effort tiers: inject the documented `reasoning_effort` param and + // force thinking on — 5.3 rejects thinking.type "disabled", and an effort + // tier without thinking would silently drop the selector upstream. + if (record && effortTier && effortTier.transport === "openai") { + const existingThinking = asRecord(record.thinking); + record.thinking = { ...existingThinking, type: "enabled" }; + record.reasoning_effort = effortTier.effort; + } if (record && stream && hasTools(record) && record.tool_stream === undefined) { return { ...record, tool_stream: true }; } @@ -446,7 +478,12 @@ export class GlmExecutor extends DefaultExecutor { */ private async finalizeAnthropicTransportResult( input: ExecuteInput, - result: { response: Response; url: string; headers: Record; transformedBody: unknown } + result: { + response: Response; + url: string; + headers: Record; + transformedBody: unknown; + } ): Promise { const { response: rawResponse, url, headers, transformedBody } = result; const clientHeaders = input.clientHeaders ?? {}; @@ -475,13 +512,14 @@ export class GlmExecutor extends DefaultExecutor { } async execute(input: ExecuteInput): Promise { - const effortTier = parseGlm52Effort(input.model); + const effortTier = parseGlmEffortTier(input.model); - // GLM-5.2 effort tiers route directly through Anthropic transport (no fallback). - // Zhipu only graduates effort on the Anthropic endpoint via the - // effort-2025-11-24 beta header included in GLM_ANTHROPIC_BETA. + // Effort tiers route directly through their family's transport (no fallback): + // GLM-5.2 → Anthropic (Zhipu only graduates effort there, via the + // effort-2025-11-24 beta header in GLM_ANTHROPIC_BETA); GLM-5.3 → OpenAI + // coding endpoint (`reasoning_effort` param). See parseGlmEffortTier. if (effortTier) { - return this.executeTransport(input, "anthropic"); + return this.executeTransport(input, effortTier.transport); } const primaryTransport = getGlmTransport( diff --git a/src/shared/constants/modelSpecs.ts b/src/shared/constants/modelSpecs.ts index 653cd2f51e..f26b3f653f 100644 --- a/src/shared/constants/modelSpecs.ts +++ b/src/shared/constants/modelSpecs.ts @@ -66,7 +66,14 @@ const BEDROCK_CLAUDE_ALIASES = (...modelIds: string[]) => [ // Provider discovery/sync sources can under-report GLM-5.2 IDs as 128K. // Keep native/bare Z.AI GLM-5.2 context authoritative, but do not blindly apply // it to every provider-wrapped alias: hosted providers can and do cap lower. -const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set(["glm-5.2", "glm-5.2-high", "glm-5.2-max"]); +const AUTHORITATIVE_CONTEXT_WINDOW_MODEL_IDS = new Set([ + "glm-5.3", + "glm-5.3-high", + "glm-5.3-low", + "glm-5.2", + "glm-5.2-high", + "glm-5.2-max", +]); const AUTHORITATIVE_PROVIDER_CONTEXT_WINDOWS = new Map([ ["cloudflare-ai/@cf/zai-org/glm-5.2", 262144], // Hugging Face Router has 1M-capable backends, but bare routing can select @@ -567,6 +574,30 @@ export const MODEL_SPECS: Record = { supportsTools: true, }, + // ── Z.AI GLM-5.3 (1M context mirrored from 5.2 — same base model; 128K max + // output; effort via reasoning_effort param, tiers are OmniRoute aliases) ── + "glm-5.3": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, + "glm-5.3-high": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, + "glm-5.3-low": { + maxOutputTokens: 131072, + contextWindow: 1000000, + thinkingBudgetCap: 38912, + supportsThinking: true, + supportsTools: true, + }, + // ── Z.AI GLM-5.2 (1M context, 128K max output, effort tiers) ──── "glm-5.2": { maxOutputTokens: 131072, diff --git a/src/shared/constants/pricing/shared-tiers.ts b/src/shared/constants/pricing/shared-tiers.ts index 325ac84fda..8bd2e4ae4f 100644 --- a/src/shared/constants/pricing/shared-tiers.ts +++ b/src/shared/constants/pricing/shared-tiers.ts @@ -111,6 +111,30 @@ export const CLAUDE_SONNET_5_PRICING = { }; export const GLM_PRICING = { + // GLM-5.3 (2026-08-14): Z.ai hasn't published 5.3 rates yet — mirrored from + // GLM-5.2 (same base model; 5.1 and 5.2 also share identical rates). + // Correct when https://docs.z.ai/guides/overview/pricing lists glm-5.3. + "glm-5.3": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, + "glm-5.3-high": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, + "glm-5.3-low": { + input: 1.2, + output: 5, + cached: 0.3, + reasoning: 5, + cache_creation: 1.2, + }, "glm-5.2": { input: 1.2, output: 5, diff --git a/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts new file mode 100644 index 0000000000..d14c136e7b --- /dev/null +++ b/tests/unit/glm-5.3-catalog-and-effort-tiers.test.ts @@ -0,0 +1,180 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// GLM-5.3 support (released 2026-08-14, https://z.ai/blog/glm-5.3). +// +// Upstream ships ONE model id (`glm-5.3`) — effort is a request parameter +// (`reasoning_effort`: low|high|max, default max) on the coding chat/completions +// endpoint, and `thinking.type: "disabled"` is rejected (converted to low by the +// coding endpoint). OmniRoute keeps the GLM-5.2 tier UX: `glm-5.3-high` / +// `glm-5.3-low` pseudo-ids resolved by the GlmExecutor only. Base `glm-5.3` uses +// the upstream default (max). Unlike the 5.2 tiers (Anthropic-transport effort +// beta header), the 5.3 tiers use the documented `reasoning_effort` param on the +// OpenAI coding transport. +// +// Spec caveat: Z.ai has not yet published the default context window — 1M is +// mirrored from GLM-5.2 (same base model) per operator decision; correct when +// the official spec lands. + +const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts"); +const { GlmExecutor } = await import("../../open-sse/executors/glm.ts"); +const { MODEL_SPECS } = await import("../../src/shared/constants/modelSpecs.ts"); +const { GLM_PRICING } = await import("../../src/shared/constants/pricing/shared-tiers.ts"); + +const GLM_5_3_IDS = ["glm-5.3", "glm-5.3-high", "glm-5.3-low"] as const; + +// transformForTransport returns an opaque body; surface only the fields asserted below. +type TransformedRequest = { + model?: string; + reasoning_effort?: string; + thinking?: { type?: string } | null; + max_tokens?: number; + effort?: string; +}; + +function modelIds(provider: string): string[] { + const entry = getRegistryEntry(provider); + assert.ok(entry, `provider "${provider}" should be registered`); + return (entry.models ?? []).map((m) => m.id); +} + +for (const provider of ["glm", "glm-cn", "glmt"]) { + test(`${provider} advertises the GLM-5.3 base model and effort tiers (GLM_SHARED_MODELS)`, () => { + const ids = modelIds(provider); + for (const id of GLM_5_3_IDS) { + assert.ok(ids.includes(id), `${provider} should expose ${id}; got ${ids.join(", ")}`); + } + }); + + test(`${provider} GLM-5.3 entries mirror the GLM-5.2 shape (1M ctx, 128K out)`, () => { + const models = getRegistryEntry(provider)!.models ?? []; + const base = models.find((m) => m.id === "glm-5.3"); + assert.ok(base, "glm-5.3 entry missing"); + assert.equal(base.contextLength, 1_000_000); + assert.equal(base.maxOutputTokens, 131_072); + assert.equal(base.toolCalling, true); + assert.equal(base.supportsReasoning, true); + }); +} + +test("zai advertises the GLM-5.3 base model only (DefaultExecutor sends ids verbatim)", () => { + const ids = modelIds("zai"); + assert.ok(ids.includes("glm-5.3"), `zai should advertise glm-5.3; got ${ids.join(", ")}`); + for (const alias of ["glm-5.3-high", "glm-5.3-low"]) { + assert.ok( + !ids.includes(alias), + `zai must not list ${alias}: GlmExecutor-only alias, unknown upstream on the Anthropic endpoint` + ); + } +}); + +test("modelSpecs carries 1M/128K specs for all GLM-5.3 ids", () => { + for (const id of GLM_5_3_IDS) { + const spec = MODEL_SPECS[id]; + assert.ok(spec, `MODEL_SPECS should include ${id}`); + assert.equal(spec.contextWindow, 1_000_000); + assert.equal(spec.maxOutputTokens, 131_072); + assert.equal(spec.supportsThinking, true); + } +}); + +test("GLM_PRICING covers the GLM-5.3 ids with GLM-5.2-parity rates", () => { + const reference = GLM_PRICING["glm-5.2"]; + assert.ok(reference, "glm-5.2 pricing reference missing"); + for (const id of GLM_5_3_IDS) { + const pricing = GLM_PRICING[id]; + assert.ok(pricing, `GLM_PRICING should include ${id}`); + assert.deepEqual(pricing, reference); + } +}); + +test("GlmExecutor resolves glm-5.3-high to reasoning_effort=high on the OpenAI coding transport", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3-high", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3", "upstream must receive the base model id"); + assert.equal(transformed.reasoning_effort, "high"); + assert.equal(transformed.thinking?.type, "enabled"); +}); + +test("GlmExecutor resolves glm-5.3-low to reasoning_effort=low with thinking enabled", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3-low", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3"); + assert.equal(transformed.reasoning_effort, "low"); + assert.equal(transformed.thinking?.type, "enabled"); +}); + +test("GlmExecutor leaves base glm-5.3 without an injected reasoning_effort (upstream default = max)", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.3", + { model: "glm-5.3", messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "openai" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.3"); + assert.equal(transformed.reasoning_effort, undefined); + // Thinking-model max_tokens default applies to 5.3 (GLM_THINKING_MODEL_PATTERN) + assert.equal(transformed.max_tokens, 131_072); +}); + +test("GLM-5.3 effort tiers execute on the OpenAI coding transport (no Anthropic-only pinning)", async () => { + const executor = new GlmExecutor("glm"); + const originalFetch = globalThis.fetch; + const calls: string[] = []; + + globalThis.fetch = async (url) => { + calls.push(String(url)); + return new Response( + 'data: {"id":"chatcmpl-glm53","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}\n\ndata: [DONE]\n\n', + { headers: { "Content-Type": "text/event-stream" } } + ); + }; + + try { + await executor.execute({ + model: "glm-5.3-high", + body: { messages: [{ role: "user", content: "hello" }] }, + stream: true, + credentials: { + apiKey: "glm-key", + providerSpecificData: { baseUrl: "https://api.z.ai/api/coding/paas/v4" }, + }, + }); + + assert.deepEqual(calls, ["https://api.z.ai/api/coding/paas/v4/chat/completions"]); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("GLM-5.2 effort tiers still pin the Anthropic transport (effort beta header) — regression guard", () => { + const executor = new GlmExecutor("glm"); + const transformed = executor.transformForTransport( + "glm-5.2-max", + { messages: [{ role: "user", content: "hi" }] }, + false, + { apiKey: "glm-key" }, + "anthropic" + ) as TransformedRequest; + + assert.equal(transformed.model, "glm-5.2"); + assert.equal(transformed.effort, "max"); + assert.equal(transformed.thinking?.type, "enabled"); +}); diff --git a/tests/unit/zai-catalog-glm52.test.ts b/tests/unit/zai-catalog-glm52.test.ts index 41e8196c0b..0e2eae4d05 100644 --- a/tests/unit/zai-catalog-glm52.test.ts +++ b/tests/unit/zai-catalog-glm52.test.ts @@ -10,7 +10,7 @@ import assert from "node:assert/strict"; // // The `zai` provider uses the DefaultExecutor, which sends the requested model ID // verbatim. The effort tiers `glm-5.2-high` / `glm-5.2-max` are OmniRoute aliases -// that only the GlmExecutor knows how to resolve (parseGlm52Effort → base model +// that only the GlmExecutor knows how to resolve (parseGlmEffortTier → base model // "glm-5.2" + `effort` field + effort-2025-11-24 beta header). Listing them under // `zai` would send unknown model IDs to z.ai's Anthropic endpoint, so they belong // to the `glm` provider only.