From 049ffe7b0b7aee0ff8a609f361c85367e21f75cd Mon Sep 17 00:00:00 2001 From: HUAN2022A <> Date: Fri, 11 Sep 2026 23:35:29 +0800 Subject: [PATCH] fix: map reasoning controls onto Zhipu BigModel's generation-specific thinking API Structured ingest on Zhipu BigModel endpoints failed with 'Model produced N chars of reasoning, but no actual response content': GLM thinking models spent the whole budget on reasoning_content, and the app's reasoning-off request was silently dropped because open.bigmodel.cn fell into the generic custom-gateway bucket. Wire the controls per generation, matching Zhipu's documented support: - capability layer: glm-4.5..5.1 offer auto/off; glm-5.2+ add low/high/max (medium and custom budgets are not representable); pre-4.5 ids stay auto-only, mirroring DeepSeek's gating - wire layer: reasoning off sends thinking.type=disabled on 4.5..5.2, but glm-5.3+ rejects that field with error 1210 (thinking is always on), so it maps to reasoning_effort=low; effort levels map to thinking.enabled + reasoning_effort on 5.2+ Verified against open.bigmodel.cn/api/paas/v4 with glm-5.3-flash: reasoning_effort=low returns 0 reasoning chars where the default (max) returned ~1k before the answer. --- src/lib/llm-providers.test.ts | 85 +++++++++++++++++++++++++- src/lib/llm-providers.ts | 50 +++++++++++++-- src/lib/reasoning-capabilities.test.ts | 39 +++++++++++- src/lib/reasoning-capabilities.ts | 43 +++++++++++++ 4 files changed, 209 insertions(+), 8 deletions(-) diff --git a/src/lib/llm-providers.test.ts b/src/lib/llm-providers.test.ts index f1bb2085a..0d65a80a9 100644 --- a/src/lib/llm-providers.test.ts +++ b/src/lib/llm-providers.test.ts @@ -25,8 +25,9 @@ import { supportsImageInput, type ChatMessage, type ContentBlock, + type RequestOverrides, } from "./llm-providers" -import type { LlmConfig } from "@/stores/wiki-store" +import type { LlmConfig, ReasoningConfig, ReasoningMode } from "@/stores/wiki-store" const TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABXvMqOgAAAABJRU5ErkJggg==" @@ -673,6 +674,88 @@ describe("reasoning controls", () => { expect(body.max_completion_tokens).toBe(4096) }) + function glmBody(model: string, reasoning: ReasoningConfig, temperature?: number): Record { + const cfg = mkConfig({ + provider: "custom", + model, + customEndpoint: "https://open.bigmodel.cn/api/paas/v4", + apiMode: "chat_completions", + }) + const overrides: RequestOverrides = { reasoning } + if (temperature !== undefined) overrides.temperature = temperature + return getProviderConfig(cfg) + .buildBody([{ role: "user", content: "hi" }], overrides) as Record + } + + it("maps GLM-4.5..5.2 reasoning off to thinking disabled on the BigModel endpoint", () => { + for (const model of ["glm-4.7-flash", "glm-4.5", "glm-5.1", "glm-5v-turbo"]) { + const body = glmBody(model, { mode: "off" }, 0.1) + expect(body.thinking).toEqual({ type: "disabled" }) + expect(body.reasoning_effort).toBeUndefined() + expect(body.temperature).toBe(0.1) + } + }) + + it("maps always-thinking GLM-5.3+ reasoning off to reasoning_effort low, never thinking disabled", () => { + // GLM-5.3 rejects `thinking.type=disabled` with error 1210; the lowest + // effort level is the only way to stop reasoning-only empty responses. + for (const model of ["glm-5.3-flash", "GLM-5.3", "glm-5.10"]) { + const body = glmBody(model, { mode: "off" }) + expect(body.reasoning_effort).toBe("low") + expect(body.thinking).toBeUndefined() + } + }) + + it("maps GLM-5.2+ effort levels to thinking enabled plus reasoning_effort", () => { + const effortModes: ReasoningMode[] = ["low", "high", "max"] + for (const mode of effortModes) { + const body = glmBody("glm-5.3-flash", { mode }) + expect(body.thinking).toEqual({ type: "enabled" }) + expect(body.reasoning_effort).toBe(mode) + } + }) + + it("leaves GLM requests untouched on auto and on unrepresentable modes", () => { + const normalized: ReasoningConfig[] = [ + { mode: "auto" }, + { mode: "medium" }, + { mode: "custom", budgetTokens: 2048 }, + ] + for (const reasoning of normalized) { + const body = glmBody("glm-5.3-flash", reasoning) + expect(body.thinking).toBeUndefined() + expect(body.reasoning_effort).toBeUndefined() + } + // Toggle-only generations normalize effort levels away instead of sending them. + const toggleOnly = glmBody("glm-4.7", { mode: "high" }) + expect(toggleOnly.thinking).toBeUndefined() + expect(toggleOnly.reasoning_effort).toBeUndefined() + }) + + it("does not send thinking controls to pre-4.5 GLM models or non-GLM ids on BigModel", () => { + for (const model of ["glm-4-flash-250414", "glm-4v-plus", "glm-z1-flash"]) { + const body = glmBody(model, { mode: "off" }) + expect(body.thinking).toBeUndefined() + expect(body.reasoning_effort).toBeUndefined() + } + }) + + it("does not apply GLM thinking controls to GLM model ids on a generic gateway", () => { + const cfg = mkConfig({ + provider: "custom", + model: "glm-5.3-flash", + customEndpoint: "https://gateway.example/v1", + apiMode: "chat_completions", + }) + const body = getProviderConfig(cfg).buildBody( + [{ role: "user", content: "hi" }], + { reasoning: { mode: "off" } }, + ) as Record + + expect(body.thinking).toBeUndefined() + expect(body.reasoning_effort).toBeUndefined() + }) + it("uses Bearer auth for Xiaomi MiMo Token Plan Anthropic wire", () => { const cfg = mkConfig({ provider: "custom", diff --git a/src/lib/llm-providers.ts b/src/lib/llm-providers.ts index 2009c7f06..70f6a4431 100644 --- a/src/lib/llm-providers.ts +++ b/src/lib/llm-providers.ts @@ -7,9 +7,12 @@ import { } from "@/lib/azure-openai" import { isAdaptiveAnthropicModel, + isBigModelEndpoint, isGeminiThinkingLevelModel, + isGlmAtLeast, isOpenRouterEndpoint, normalizeReasoningForProvider, + parseGlmVersion, } from "@/lib/reasoning-capabilities" /** @@ -365,10 +368,6 @@ function isXiaomiMimoEndpoint(config: LlmConfig): boolean { return /\.?xiaomimimo\.com(?::|\/|$)/i.test(config.customEndpoint) } -function isBigModelEndpoint(config: LlmConfig): boolean { - return /(?:^|\/\/)open\.bigmodel\.cn(?:[:/]|$)/i.test(config.customEndpoint) -} - function isGlmVisionModel(model: string): boolean { const normalized = model.trim().toLowerCase() return /(?:^|[-_.])glm[-_.]5v[-_.]turbo(?:[-_.]|$)/i.test(normalized) @@ -447,6 +446,44 @@ function adaptXiaomiMimoBody( } } +function adaptBigModelBody( + config: LlmConfig, + body: Record, + reasoning: ReasoningConfig, +): void { + if (!isBigModelEndpoint(config.customEndpoint)) return + const version = parseGlmVersion(config.model) + // Pre-4.5 GLM models (and non-GLM ids routed through BigModel) predate + // the thinking controls; the capability layer already narrows them to + // auto, so there is nothing to translate. + if (!version || !isGlmAtLeast(version, 4, 5)) return + + if (reasoning.mode === "off") { + // GLM-5.3+ thinks unconditionally and rejects `thinking.type=disabled` + // with error 1210 ("该模型始终思考,不支持关闭思考;请使用 low、high 或 + // max"). `reasoning_effort=low` is the floor those models expose, and it + // is what keeps structured ingest from spending the whole budget on + // `reasoning_content` and returning empty `content`. + if (isGlmAtLeast(version, 5, 3)) { + body.reasoning_effort = "low" + } else { + body.thinking = { type: "disabled" } + } + return + } + + // `reasoning_effort` is a GLM-5.2+ field. Earlier generations only have + // the on/off toggle, which the capability layer reflects by never handing + // an effort level to this adapter for them. + if ( + isGlmAtLeast(version, 5, 2) + && (reasoning.mode === "low" || reasoning.mode === "high" || reasoning.mode === "max") + ) { + body.thinking = { type: "enabled" } + body.reasoning_effort = reasoning.mode + } +} + function buildOpenAiCompatibleBody( config: LlmConfig, messages: ChatMessage[], @@ -463,6 +500,7 @@ function buildOpenAiCompatibleBody( adaptOpenAiStrictCompletionBody(config, body) adaptKimiBody(config, body) adaptXiaomiMimoBody(config, body, reasoning) + adaptBigModelBody(config, body, reasoning) if (config.provider === "custom" && isOpenRouterEndpoint(config.customEndpoint)) { if (reasoning.mode === "custom" && reasoning.budgetTokens !== undefined) { @@ -751,7 +789,7 @@ function assertMiniMaxImageSupport(url: string, model: string, messages: ChatMes } function assertBigModelImageSupport(config: LlmConfig, messages: ChatMessage[]): void { - if (!isBigModelEndpoint(config) || !hasImageContent(messages) || isGlmVisionModel(config.model)) return + if (!isBigModelEndpoint(config.customEndpoint) || !hasImageContent(messages) || isGlmVisionModel(config.model)) return throw new Error( "Zhipu BigModel image input is supported only by GLM vision models. Switch to glm-5v-turbo, glm-4.6v, glm-4.5v, or glm-4v-plus.", ) @@ -760,7 +798,7 @@ function assertBigModelImageSupport(config: LlmConfig, messages: ChatMessage[]): export function supportsImageInput(config: LlmConfig): boolean { if (config.provider === "codex-cli") return false if (config.provider === "minimax") return isMiniMaxM3Model(config.model) - if (isBigModelEndpoint(config)) return isGlmVisionModel(config.model) + if (isBigModelEndpoint(config.customEndpoint)) return isGlmVisionModel(config.model) if ((config.provider === "custom") && (config.apiMode ?? "chat_completions") === "anthropic_messages") { const url = buildAnthropicUrl(config.customEndpoint) return !isOfficialMiniMaxAnthropicUrl(url) || isMiniMaxM3Model(config.model) diff --git a/src/lib/reasoning-capabilities.test.ts b/src/lib/reasoning-capabilities.test.ts index e8b40a674..a90f3c6e3 100644 --- a/src/lib/reasoning-capabilities.test.ts +++ b/src/lib/reasoning-capabilities.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" import type { LlmConfig } from "@/stores/wiki-store" -import { normalizeReasoningForProvider, resolveReasoningCapabilities } from "./reasoning-capabilities" +import { + normalizeReasoningForProvider, + parseGlmVersion, + resolveReasoningCapabilities, +} from "./reasoning-capabilities" function config(provider: LlmConfig["provider"], model: string): LlmConfig { return { @@ -40,6 +44,39 @@ describe("reasoning capabilities", () => { .not.toContain("custom") }) + it("offers Zhipu GLM thinking controls by generation on the BigModel endpoint", () => { + const glm = (model: string) => ({ + ...config("custom", model), + customEndpoint: "https://open.bigmodel.cn/api/paas/v4", + }) + + // Pre-4.5 models predate `thinking` entirely. + expect(resolveReasoningCapabilities(glm("glm-4-flash-250414")).modes).toEqual(["auto"]) + expect(resolveReasoningCapabilities(glm("glm-z1-flash")).modes).toEqual(["auto"]) + // GLM-4.5 .. 5.1 only have the on/off toggle. + expect(resolveReasoningCapabilities(glm("glm-4.7-flash")).modes).toEqual(["auto", "off"]) + expect(resolveReasoningCapabilities(glm("glm-5.1")).modes).toEqual(["auto", "off"]) + // GLM-5.2+ adds low/high/max; medium and custom budgets never exist. + expect(resolveReasoningCapabilities(glm("glm-5.3-flash")).modes) + .toEqual(["auto", "off", "low", "high", "max"]) + expect(normalizeReasoningForProvider(glm("glm-5.3-flash"), { mode: "medium" })) + .toEqual({ mode: "auto" }) + expect(normalizeReasoningForProvider(glm("glm-4.7"), { mode: "high" })) + .toEqual({ mode: "auto" }) + // The same model ids through a generic gateway stay auto-only. + expect(resolveReasoningCapabilities(config("custom", "glm-5.3-flash")).modes).toEqual(["auto"]) + }) + + it("parses GLM generations out of model ids", () => { + expect(parseGlmVersion("glm-4.7-flash")).toEqual({ major: 4, minor: 7 }) + expect(parseGlmVersion("GLM-5.3-Flash")).toEqual({ major: 5, minor: 3 }) + expect(parseGlmVersion("glm-5v-turbo")).toEqual({ major: 5, minor: 0 }) + expect(parseGlmVersion("glm-4.5v")).toEqual({ major: 4, minor: 5 }) + expect(parseGlmVersion("zhipu/glm-4.6")).toEqual({ major: 4, minor: 6 }) + expect(parseGlmVersion("glm-z1-flash")).toBeNull() + expect(parseGlmVersion("deepseek-v4-flash")).toBeNull() + }) + it("limits OpenAI reasoning models to representable effort levels", () => { expect(resolveReasoningCapabilities(config("openai", "gpt-5.4")).modes) .toEqual(["auto", "low", "medium", "high"]) diff --git a/src/lib/reasoning-capabilities.ts b/src/lib/reasoning-capabilities.ts index a10321d77..5c390d3ef 100644 --- a/src/lib/reasoning-capabilities.ts +++ b/src/lib/reasoning-capabilities.ts @@ -16,6 +16,7 @@ const THINKING_REQUIRED_LEVELS = ["auto", "low", "medium", "high", "max"] as con const OLLAMA_LEVELS = ["auto", "off", "low", "medium", "high", "max"] as const const TOGGLE_LEVELS = ["auto", "off"] as const const DEEPSEEK_LEVELS = ["auto", "off", "high", "max"] as const +const GLM_EFFORT_LEVELS = ["auto", "off", "low", "high", "max"] as const function capabilities( modes: readonly ReasoningMode[], @@ -63,6 +64,45 @@ export function isOpenRouterEndpoint(endpoint: string): boolean { } } +export function isBigModelEndpoint(endpoint: string): boolean { + return /(?:^|\/\/)open\.bigmodel\.cn(?:[:/]|$)/i.test(endpoint) +} + +export interface GlmVersion { + major: number + minor: number +} + +/** + * Generation of a Zhipu GLM model id: "glm-4.7-flash" → 4.7, "glm-5v-turbo" + * → 5.0, "GLM-5.3-Flash" → 5.3. Lines without a generation number (glm-z1-*) + * and non-GLM ids return null. + */ +export function parseGlmVersion(model: string): GlmVersion | null { + const match = /(?:^|[-_./])glm[-_.]?(\d+)(?:\.(\d+))?/i.exec(model.trim()) + if (!match) return null + return { major: Number(match[1]), minor: match[2] ? Number(match[2]) : 0 } +} + +export function isGlmAtLeast(version: GlmVersion, major: number, minor: number): boolean { + return version.major > major || (version.major === major && version.minor >= minor) +} + +/** + * Zhipu's thinking controls arrived generation by generation: `thinking.type` + * ("enabled" | "disabled") with GLM-4.5, `reasoning_effort` ("low" | "high" | + * "max") with GLM-5.2, and from GLM-5.3 thinking is always on and can only be + * dialed down. "off" stays offered on every 4.5+ model — the wire layer maps + * it to whatever the generation permits — while "medium" and custom budgets + * are not representable anywhere in the lineup. + * See docs.bigmodel.cn/cn/guide/capabilities/thinking. + */ +function glmReasoningModes(model: string): readonly ReasoningMode[] { + const version = parseGlmVersion(model) + if (!version || !isGlmAtLeast(version, 4, 5)) return AUTO_ONLY + return isGlmAtLeast(version, 5, 2) ? GLM_EFFORT_LEVELS : TOGGLE_LEVELS +} + /** * Resolve only capabilities that are part of the selected wire contract. * Generic custom gateways deliberately stay Auto-only: a vendor-looking @@ -100,6 +140,9 @@ export function resolveReasoningCapabilities(config: LlmConfig): ReasoningCapabi if (/xiaomimimo\.com(?:[:/]|$)/.test(endpoint)) { return capabilities(TOGGLE_LEVELS) } + if (isBigModelEndpoint(endpoint)) { + return capabilities(glmReasoningModes(config.model)) + } // Anthropic-compatible custom endpoints are not necessarily Anthropic // itself (MiniMax, Kimi and enterprise proxies differ), so omission is the // only portable default. Users can select a first-party preset when they