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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/features/02-20260517-per-model-thinking-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
12 changes: 12 additions & 0 deletions src/retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/test/retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
58 changes: 58 additions & 0 deletions src/test/thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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" });
Expand Down
60 changes: 50 additions & 10 deletions src/thinking/glm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<string, unknown>): 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<string, unknown> {
if (thinking.glm === "off") {
return { thinking: { type: "disabled" } };
Expand Down
Loading