diff --git a/CHANGELOG.md b/CHANGELOG.md index 9846d42..5dcde7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente - **DeepSeek / Mimo thinking content no longer leaks into the chat transcript.** `treatReasoningAsContent` was mis-detecting native-reasoning families as "no reasoning in body" and echoing their `reasoning_content` as plain chat text. The decision now comes from the provider strategy (always `false` for DeepSeek and Mimo), so chain-of-thought stays in the thinking panel. +- **`[Thinking]` GLM 5.3+ can no longer be disabled (HTTP 400, #162).** GLM 5.3 and later reject `thinking: { type: "disabled" }` with _"This model always engages in thinking and cannot be disabled; please use low, high, or max"_. `GlmThinking` now detects GLM 5.3+ by version and forces thinking on (default `high`; the picker exposes only `high`/`max` and hides `off`), mirroring the Kimi K2.7-code force-on fix. A defensive retry pattern in `retry.ts` also strips `thinking` when the upstream reports it cannot be disabled, so any stale cached `off` is recovered automatically. Unit tests added for version detection, forced-on resolution, payload shape and picker schema. + ## [0.6.0] — 2026-08-13 ### Added diff --git a/docs/features/02-20260517-per-model-thinking-controls.md b/docs/features/02-20260517-per-model-thinking-controls.md index 449e30c..e14a4cd 100644 --- a/docs/features/02-20260517-per-model-thinking-controls.md +++ b/docs/features/02-20260517-per-model-thinking-controls.md @@ -59,6 +59,8 @@ The feature adds explicit settings under the `opencodego.thinking.*` namespace: These settings act as persistent defaults and as a fallback when the VS Code model picker does not expose native model configuration controls. +> **GLM 5.3+ cannot disable thinking (issue #162).** GLM 5.3 and later reject `thinking: { type: "disabled" }` with _"This model always engages in thinking and cannot be disabled"_. The `glm` strategy detects 5.3+ by version, forces thinking on (default `high`), and hides `off` from the picker — mirroring the Kimi K2.7-code force-on behavior. A defensive retry pattern also strips `thinking` when the upstream reports it cannot be disabled. + ### Native Model Configuration Schema Thinking-capable models publish a `configurationSchema` through `LanguageModelChatInformation`. diff --git a/src/retry.ts b/src/retry.ts index 1973714..17f2da4 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -65,6 +65,18 @@ const RECOVERABLE_ERROR_PATTERNS: { }, describe: () => "removed thinking field (model requires disabled)", }, + // "This model always engages in thinking and cannot be disabled" (GLM 5.3+) + // Scoped to thinking-related phrasing so unrelated "cannot be disabled" + // errors never trigger a thinking-strip retry. + { + pattern: /always engages in thinking|thinking.*cannot be disabled/i, + patch: (body) => { + const next = { ...body }; + delete next.thinking; + return next; + }, + describe: () => "removed thinking field (model cannot disable thinking)", + }, // Generic "invalid thinking" — strip the field entirely { pattern: /invalid thinking/i, diff --git a/src/test/retry.test.ts b/src/test/retry.test.ts index 08ffaad..2beec1f 100644 --- a/src/test/retry.test.ts +++ b/src/test/retry.test.ts @@ -24,6 +24,23 @@ describe("analyzeHttp400ForRetry — thinking errors", () => { assert.ok(result, "should be recoverable"); assert.deepEqual(result.body, { model: "test", temperature: 0.2 }); }); + + it("patches GLM 'cannot be disabled' by removing thinking (issue #162)", () => { + const body = { model: "glm-5.3", thinking: { type: "disabled" } }; + const result = analyzeHttp400ForRetry( + "Upstream request failed: [1210] This model always engages in thinking and cannot be disabled; please use low, high, or max", + body, + ); + assert.ok(result, "should be recoverable"); + assert.deepEqual(result.body, { model: "glm-5.3" }); + assert.match(result.reason, /cannot disable thinking/i); + }); + + it("does not patch unrelated errors that merely contain 'cannot be disabled'", () => { + const body = { model: "glm-5.3", thinking: { type: "disabled" } }; + const result = analyzeHttp400ForRetry("feature X cannot be disabled for this account", body); + assert.equal(result, undefined); + }); }); describe("analyzeHttp400ForRetry — temperature errors", () => { diff --git a/src/test/thinking.test.ts b/src/test/thinking.test.ts index ea41510..96a0491 100644 --- a/src/test/thinking.test.ts +++ b/src/test/thinking.test.ts @@ -262,6 +262,64 @@ describe("GLMThinking — picker schema", () => { }); }); +describe("GLMThinking — glm-5.3 always-thinking (issue #162)", () => { + it("forces glm='high' through resolve even with no override (defensive default)", () => { + const resolved = resolveThinkingConfig({ modelId: "glm-5.3", workspace: defaultSettings }); + assert.equal(resolved.settings.glm, "high"); + }); + + it("forces glm='high' even when override requests 'off' (stale cache)", () => { + const resolved = resolveThinkingConfig({ + modelId: "glm-5.3", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "off" }, + }); + assert.equal(resolved.settings.glm, "high"); + }); + + it("respects a valid 'max' override for glm-5.3", () => { + const resolved = resolveThinkingConfig({ + modelId: "glm-5.3", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "max" }, + }); + assert.equal(resolved.settings.glm, "max"); + }); + + it("glm-5.3 with glm='high' emits reasoning_effort: 'high' (never disabled)", () => { + const payload = thinkingProviderFor("glm-5.3").buildPayload({ ...defaultSettings, glm: "high" }); + assert.deepEqual(payload, { reasoning_effort: "high" }); + }); + + it("glm-5.3 with glm='max' emits reasoning_effort: 'max'", () => { + const payload = thinkingProviderFor("glm-5.3").buildPayload({ ...defaultSettings, glm: "max" }); + assert.deepEqual(payload, { reasoning_effort: "max" }); + }); + + it("picker schema exposes only high/max (no 'off') and defaults to 'high'", () => { + const schema = thinkingProviderFor("glm-5.3").schema(); + assert.ok(schema, "expected schema to be defined"); + const reasoningEffort = schema.properties.reasoningEffort as Record; + assert.deepEqual(reasoningEffort.enum, ["high", "max"]); + assert.deepEqual(reasoningEffort.enumItemLabels, ["High", "Max"]); + assert.equal(reasoningEffort.default, "high"); + }); + + it("detects glm-5.3 variants (dot/hyphen, free/suffix) as always-thinking", () => { + for (const id of ["glm-5.3", "glm-5.3-free", "glm-5-3", "glm-5.10", "glm-5.3-code"]) { + const resolved = resolveThinkingConfig({ modelId: id, workspace: defaultSettings }); + assert.equal(resolved.settings.glm, "high", `expected ${id} to force thinking on`); + } + }); + + it("still allows glm-5.2 / glm-5.1 / glm-5 to disable thinking", () => { + for (const id of ["glm-5.2", "glm-5.1", "glm-5"]) { + const payload = thinkingProviderFor(id).buildPayload({ ...defaultSettings, glm: "off" }); + assert.deepEqual(payload, { thinking: { type: "disabled" } }, `expected ${id} to allow disabled`); + } + }); +}); + describe("QwenThinking — payload per endpoint", () => { it("chat endpoint with qwen='off' emits enable_thinking: false", () => { const payload = thinkingProviderFor("qwen3.6-plus").buildPayload({ ...defaultSettings, qwen: "off" }); diff --git a/src/thinking/glm.ts b/src/thinking/glm.ts index 40ee2c5..da3d5b1 100644 --- a/src/thinking/glm.ts +++ b/src/thinking/glm.ts @@ -10,7 +10,20 @@ import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; +// Standard GLM models accept off/high/max (issue #61). const GLM_EFFORTS = ["off", "high", "max"] as const; +// GLM 5.3+ always engage in thinking and reject `disabled` (issue #162); the +// upstream accepts high/max reasoning effort, so we expose only those. +const GLM_ALWAYS_THINKING_EFFORTS = ["high", "max"] as const; + +/** + * GLM 5.3 and later cannot disable thinking (Zhipu API constraint — the same + * class of issue as Kimi K2.7-code in #25). Detected by version so future 5.x + * releases are covered automatically. + */ +function isAlwaysThinking(modelId: string): boolean { + return /^glm-5[.\-](?:[3-9]|\d{2,})/i.test(modelId); +} export class GlmThinking extends BaseThinkingProvider { readonly family: ThinkingFamily = "glm"; @@ -20,26 +33,53 @@ export class GlmThinking extends BaseThinkingProvider { } schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { - return ( - schemaFromReasoningOptions(metadata) ?? { + const fromOptions = schemaFromReasoningOptions(metadata); + if (fromOptions) return fromOptions; + + // GLM 5.3+ cannot disable thinking — expose only the effort levels the + // upstream accepts, defaulting to "high" (defensive against stale cache). + if (isAlwaysThinking(this.modelId)) { + return { properties: { reasoningEffort: effortProperty({ - enum: GLM_EFFORTS, - labels: ["Off", "High", "Max"], - descriptions: ["Fastest responses", "Greater reasoning depth", "Maximum reasoning effort"], - default: "off", + enum: GLM_ALWAYS_THINKING_EFFORTS, + labels: ["High", "Max"], + descriptions: ["Greater reasoning depth", "Maximum reasoning effort"], + default: "high", }), }, - } - ); + }; + } + + return { + properties: { + reasoningEffort: effortProperty({ + enum: GLM_EFFORTS, + labels: ["Off", "High", "Max"], + descriptions: ["Fastest responses", "Greater reasoning depth", "Maximum reasoning effort"], + default: "off", + }), + }, + }; } applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { - let next = this.applyEffort(settings, override, "glm", GLM_EFFORTS); - next = this.applyMode(next, override, "glm", GLM_EFFORTS); + const efforts = isAlwaysThinking(this.modelId) ? GLM_ALWAYS_THINKING_EFFORTS : GLM_EFFORTS; + let next = this.applyEffort(settings, override, "glm", efforts); + next = this.applyMode(next, override, "glm", efforts); return next; } + normalize(settings: ThinkingSettings): ThinkingSettings { + // GLM 5.3+ forces thinking on regardless of picker selection (defensive — + // the picker only exposes effort levels, but VS Code may cache a stale + // "off" value). The upstream rejects `thinking: { type: "disabled" }`. + if (isAlwaysThinking(this.modelId) && settings.glm === "off") { + return { ...settings, glm: "high" }; + } + return settings; + } + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { if (thinking.glm === "off") { return { thinking: { type: "disabled" } };