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
26 changes: 13 additions & 13 deletions ARCHITECTURE-MAP.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to the **OpenCode Go BYOK Provider** extension are documented here.

## [0.7.9] — 2026-10-04

### Fixed

- **`[Thinking]` The per-model Reasoning Effort picker now actually works for models with no known thinking family (#252).** For any model not matched by a hardcoded family in `core/registry.ts` (`space-bunny-free`, `big-pickle`, `hy3`, …) the picker rendered a Thinking Effort control whose selection was silently discarded — the request never carried a reasoning field, so the model always answered at its default effort. Two independent defects: `FallbackThinking` had no payload path at all (`buildPayload` returned `{}`), _and_ all four `buildPayload` call sites called `thinkingProviderFor(modelId)` without metadata, so any metadata-driven gate could never fire. models.dev is now the source of truth: `metadata.reasoning` decides whether a model gets a control, its own `reasoning_options` decide which levels (falling back to a shared `off`/`low`/`medium`/`high`/`xhigh`/`max` ladder), and the pick is sent as a plain OpenAI-compatible `reasoning_effort`. A new `opencodego.thinking.generic` setting provides a workspace-wide default. Family-less models routed to non-chat endpoints (`claude-*` → Anthropic messages, `gemini-*` → Google, `grok-*` → Responses) are deliberately excluded — `reasoning_effort` is meaningless on those wire formats — and the `big-pickle` repetition penalty (issue #207) is preserved and composes with the effort field. Fixing this exposed a second bug in the same feature: `prepareChatRequest` resolved thinking config _before_ metadata was available and without passing it, so the generic strategy dropped the per-model pick outright while the workspace setting kept working — a confusing split where the Copilot dropdown did nothing but settings worked. Metadata is now a required field on `ResolveThinkingConfigInput` so the compiler rejects that call shape. Documented in `docs/issues/109-20261004-issue252-generic-reasoning-effort.md`.

## [0.7.8] — 2026-09-25

### Fixed
Expand Down
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,15 +240,15 @@ GitHub Copilot has four tiers now: **Free**, **Pro ($10/mo)**, **Pro+ ($39/mo)**

Per-model reasoning configuration, dynamically enhanced with `reasoning_options` from `models.dev`:

| Family | Options | Setting |
| ------------------------------ | ------------------------------------------------------ | ------------------------------------------ |
| **DeepSeek** | `off` / `low` / `medium` / `high` / `max` | `opencodego.thinking.deepseek` |
| **GLM** | `off` / `high` / `max` | `opencodego.thinking.glm` |
| **Kimi** | `on` / `off` | `opencodego.thinking.kimi` |
| **MiniMax** | `off` / `on` | `opencodego.thinking.minimax` |
| **Mimo (Xiaomi)** | `off` / `low` / `medium` / `high` | `opencodego.thinking.mimo` |
| **Qwen** | `auto` / `on` / `off` + `thinking_budget` (4096–81920) | `opencodego.thinking.qwen` + `.qwenBudget` |
| **Any future reasoning model** | `off` / `on` (auto-detected from `models.dev`) | auto |
| Family | Options | Setting |
| ------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------ |
| **DeepSeek** | `off` / `low` / `medium` / `high` / `max` | `opencodego.thinking.deepseek` |
| **GLM** | `off` / `high` / `max` | `opencodego.thinking.glm` |
| **Kimi** | `on` / `off` | `opencodego.thinking.kimi` |
| **MiniMax** | `off` / `on` | `opencodego.thinking.minimax` |
| **Mimo (Xiaomi)** | `off` / `low` / `medium` / `high` | `opencodego.thinking.mimo` |
| **Qwen** | `auto` / `on` / `off` + `thinking_budget` (4096–81920) | `opencodego.thinking.qwen` + `.qwenBudget` |
| **Any future reasoning model** | `off` / `low` / `medium` / `high` / `xhigh` / `max` (auto-detected from `models.dev`) | `opencodego.thinking.generic` |

> **`opencodego.debugReasoning`** writes provider `reasoning_content` to **Output → OpenCode** for debugging.

Expand Down
128 changes: 128 additions & 0 deletions docs/issues/109-20261004-issue252-generic-reasoning-effort.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
# Issue #252 — Reasoning Effort Picker Ignored for Models With No Known Thinking Family

**Status:** ✅ Solved (0.7.9)
**Topic:** thinking / reasoning / metadata / models.dev / registry
**Updated:** 2026-10-04
**Tags:** #thinking #bug #models-dev #fallback #metadata
**GitHub Issue:** [#252](https://github.com/ltmoerdani/opencode-copilot-chat/issues/252)
**Related:** doc [93 — #214 thinking settings scope](93-20260903-issue214-thinking-settings-scope.md), [102 — #239 reasoning echo self-heal](102-20260923-issue239-reasoning-echo-self-heal.md), issue #207 (`big-pickle` repetition penalty), issue #226 (schema-default echo stripping), issue #3 (per-model thinking modes)

---

## Problem

For any model that does not match a hardcoded thinking family in `src/core/registry.ts`, the **Thinking Effort** control appeared in the Copilot model picker — but selecting a level did nothing. The request body never carried a reasoning field, so the model always answered at its default effort.

Reproduced on `space-bunny-free` (OpenCode Go and Zen):

1. Pick a model with no dedicated thinking family (`space-bunny-free`, `big-pickle`, `hy3`, …).
2. Open the per-model configuration and set **Thinking Effort** to a non-`off` level.
3. Send a message.

**Expected** — the request carries `reasoning_effort`.
**Actual** — no reasoning field is sent; the pick is silently discarded.

The workspace settings were equally ineffective, because the fallback emitted nothing at all.

## Root Cause — two independent defects

### 1. `FallbackThinking` had no payload path

`src/thinking/fallback.ts` returned `{}` from `buildPayload()` for every unknown model, so no reasoning field could ever be emitted:

```ts
buildPayload(_thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record<string, unknown> {
if (REPETITION_PENALIZED_MODEL_PATTERNS.some((pattern) => pattern.test(this.modelId))) {
return { repetition_penalty: GENERIC_REPETITION_PENALTY };
}
return {};
}
```

Its `schema()` already used `metadata.reasoning` to decide whether to show a picker — so the UI promised a control the request ignored. `applyOverride` was the inherited base no-op, so the pick never reached `ThinkingSettings` either.

### 2. `buildPayload` was never given metadata

All four production call sites called `thinkingProviderFor(modelId)` with no metadata, so `this.metadata` was always `undefined` inside `FallbackThinking`. **Any** metadata-driven gate would therefore never have fired, even once defect 1 was fixed.

This is the deeper trap: the thinking system reads its rules from models.dev, but the payload path was the one place that never received it. It is also why patching a single model name (`space-bunny-free`) cannot work — the model was never the problem, the strategy was.

## Fix

`metadata.reasoning` from models.dev is now the source of truth, with no per-model hardcoding.

| Layer | File | Change |
| -------------- | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| Strategy | `src/thinking/fallback.ts` | Emits `reasoning_effort` for reasoning-capable unknown models; `applyOverride` maps the pick onto settings |
| Levels | `src/thinking/schema.ts` | Generic ladder `off/low/medium/high/xhigh/max`; a model's own `reasoning_options` still takes priority |
| Settings | `src/thinking/types.ts`, `config.ts`, `provider/settings.ts`, `package.json` | New `opencodego.thinking.generic` wired through the normal per-family path |
| **Root cause** | `src/provider/chatPrep.ts`, `src/request/{openai,anthropic}.ts` | Pass `metadata` into `thinkingProviderFor(...)` at every `buildPayload` call site |
| Command | `src/commands/thinkingPicker.ts` | `Set Thinking Effort…` gained the `Generic` family entry |

### One predicate, so the picker can never lie

`FallbackThinking.genericEffortSupported()` gates the schema, the override mapping, the payload and `requestsThinking` alike. A model that gets no picker can never have one ignored, and vice versa.

It requires `metadata.reasoning === true` **and** `endpointKind === "chat-completions"`. The second clause is load-bearing: `FallbackThinking` also serves family-less models on other wire formats — `claude-*` (Anthropic messages), `gemini-*` (Google), `grok-*` (Responses). `reasoning_effort` is meaningless there, so those keep today's exact no-op behaviour instead of gaining a control that silently does nothing (or 400s the request). The check reads `endpointKind` from the shared registry, so it stays data-driven rather than becoming another name list.

### Levels come from metadata, not from code

`schemaFromReasoningOptions()` still wins when a model declares `reasoning_options`; the shared ladder only fills the gap. A model declaring a level this build has never seen (`none`, `ultra`, …) therefore works with no code change. `ThinkingSettings.generic` is deliberately typed `string` rather than a fixed union for the same reason.

The `big-pickle` repetition-penalty mitigation (issue #207) is preserved and composes with the effort field: `{ repetition_penalty: 1.2, reasoning_effort: "high" }`.

## Follow-up bug found while testing

Fixing the above exposed a second defect in the same feature, in the **per-model picker** path.

`prepareChatRequest` called `resolveThinkingConfig()` _before_ metadata was resolved and without passing it:

```ts
const resolvedThinking = resolveThinkingConfig({
modelId: rawModelId,
workspace: baseSettings.thinking,
modelConfiguration: requestOverride, // ← no metadata
});
```

`FallbackThinking.applyOverride` gates on `metadata.reasoning`, so with `this.metadata` undefined it returned settings unchanged — **the user's per-model pick was discarded outright**. The workspace setting kept working because it flows through `buildPayload`, which _does_ receive metadata. That asymmetry produced a confusing symptom: the Copilot dropdown did nothing while the same value in settings worked.

Fixed by reordering `prepareChatRequest` so metadata is resolved first, and by making `metadata` a **required** field on `ResolveThinkingConfigInput` — the compiler now rejects that call shape, so the bug cannot silently return. Note this also restores `stripSchemaDefaultEcho` (issue #226) for generic models: without metadata the picker schema is unknown, no default is stripped, and VS Code's `"off"` baseline would clobber the user's workspace pick.

## Tests

`src/test/thinking.test.ts` — 14 new cases (495 → 509, plus 3 follow-up → 512):

1. Unknown reasoning-capable model gets the generic schema with the right enum, labels and default.
2. The generic schema default tracks `THINKING_DEFAULTS.generic`.
3. Every non-off level emits a plain `reasoning_effort`.
4. `off` — the default — emits nothing and reports no thinking request.
5. A model's own `reasoning_options` take priority over the generic ladder.
6. Overrides are accepted for the model's _own_ levels (`max`), not just the ladder.
7. Full production round trip: `modelConfiguration` → `resolveThinkingConfig` → `buildPayload`.
8. The `#226` schema-default echo is stripped so the workspace pick survives.
9. A delivered pick is ignored for a model metadata says cannot reason.
10. A model with no metadata at all stays inert (unknown ≠ capable).
11. `claude-*` / `gemini-*` / `grok-*` get neither picker nor payload.
12. `big-pickle` keeps its repetition penalty alongside the effort.
13. Every known family still builds its family-specific payload shape.
14. The generic setting never changes a known family's payload (or its override mapping).

### models.dev evidence

Probed live against `https://models.dev/api.json` to confirm which models actually take the new path:

| Model | `reasoning_options` | Path exercised |
| ------------------------------------------------------------------------------- | ----------------------------------- | ---------------------------------------- |
| `space-bunny-free` | `effort: low,medium,high,xhigh,max` | metadata levels (the reported bug) |
| `hy4` | `effort: none,high` | metadata levels |
| `big-pickle`, `nemotron-3-ultra-free`, `ling-3.0-tiny-free`, `ring-2.6-1t-free` | _(empty)_ | **new generic ladder** |
| `longcat-2.*`, `ling-3.1-flash-free` | `toggle` | pre-existing `off`/`on` path (unchanged) |

The empty-`reasoning_options` group previously fell through to a picker with no payload behind it; it now gets the full `off` → `max` ladder.

Verification: **512/512** unit tests, `npm run compile` clean, `lint:js` / `lint:ts` / `prettier` clean, plus manual confirmation in the Extension Development Host on `space-bunny-free` and `big-pickle`.

## Lesson Recorded

When a subsystem's behaviour is **data-driven**, audit that the data actually reaches _every_ entry point — not just the one that was reported. Here the picker path received models.dev metadata and the payload path did not, and the resulting bug looked like "the dropdown is broken" rather than "a required input is missing". Making such an input required in the type — rather than optional and documented — converts the next occurrence of this class from a silent wrong answer into a compile error.
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,19 @@
],
"default": "off",
"markdownDescription": "Thinking mode for Muse Spark models (`muse-spark-1.2-contributor`, `muse-spark-1.2-contributor-free`). Maps to the Responses API `reasoning.effort` field. Higher effort increases reasoning depth and may increase latency and usage."
},
"opencodego.thinking.generic": {
"type": "string",
"enum": [
"off",
"low",
"medium",
"high",
"xhigh",
"max"
],
"default": "off",
"markdownDescription": "Thinking mode for models with no dedicated family — including any model released after this extension build. Applies only when models.dev reports the model as reasoning-capable; maps to `reasoning_effort` on the OpenAI-compatible endpoint. `off` sends nothing; `xhigh`/`max` ask for the deepest reasoning the model offers. Models with their own family (DeepSeek, GLM, Kimi, MiniMax, MiMo, Qwen, Muse, GPT) ignore this setting."
}
}
},
Expand Down
1 change: 1 addition & 0 deletions src/commands/thinkingPicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export async function showThinkingEffortPicker(): Promise<void> {
{ label: "Qwen (qwen3.*)", key: "qwen", options: [...THINKING_ALLOWED_VALUES.qwen] },
{ label: "Qwen Thinking Budget", key: "qwenBudget", options: [...THINKING_ALLOWED_VALUES.qwenBudget] },
{ label: "Muse Spark (muse-spark-*)", key: "muse", options: [...THINKING_ALLOWED_VALUES.muse] },
{ label: "Generic (any reasoning model without its own family)", key: "generic", options: [...THINKING_ALLOWED_VALUES.generic] },
];
const settings = getSettings().thinking;
const family = await vscode.window.showQuickPick(
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const SETTING_THINKING_QWEN = "thinking.qwen";
export const SETTING_THINKING_QWEN_BUDGET = "thinking.qwenBudget";
export const SETTING_THINKING_MIMO = "thinking.mimo";
export const SETTING_THINKING_MUSE = "thinking.muse";
export const SETTING_THINKING_GENERIC = "thinking.generic";

// ─── Inline completions (issue #49) ─────────────────────────────────────────

Expand Down Expand Up @@ -398,4 +399,5 @@ export const THINKING_DEFAULTS = {
qwenBudget: "auto",
mimo: "off",
muse: "off",
generic: "off",
} as const;
16 changes: 11 additions & 5 deletions src/provider/chatPrep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,25 +123,31 @@ export async function prepareChatRequest(

const baseSettings = getSettings();
const requestOverride = getRequestModelConfiguration(options);
// Extract the context-size tier selected by the user (if any)
const contextSizeOverride = typeof requestOverride?.contextSize === "number" ? requestOverride.contextSize : undefined;
const metadataSnapshot = await deps.getMetadataSnapshot();
const metadata = deps.resolveModelMetadata(rawModelId, metadataSnapshot);
// Resolve the effective thinking config: VS Code's per-model configuration
// (options.modelConfiguration, chatLanguageModels.json) is the SINGLE
// authority for per-model thinking; the workspace setting is the default;
// THINKING_DEFAULTS is the final fallback. No extension-side persisted
// shadow state (removed — it fought the VS Code authority and could pin a
// stale non-off value over the user's Off).
//
// `metadata` is REQUIRED here, not optional: the generic (no-known-family)
// strategy decides whether a model takes a reasoning effort purely from
// `metadata.reasoning`, so resolving without it silently discards the user's
// per-model pick and falls back to the workspace setting.
const resolvedThinking = resolveThinkingConfig({
modelId: rawModelId,
metadata,
workspace: baseSettings.thinking,
modelConfiguration: requestOverride,
});
const settings: ApiSettings = {
...baseSettings,
thinking: resolvedThinking.settings,
};
// Extract the context-size tier selected by the user (if any)
const contextSizeOverride = typeof requestOverride?.contextSize === "number" ? requestOverride.contextSize : undefined;
const metadataSnapshot = await deps.getMetadataSnapshot();
const metadata = deps.resolveModelMetadata(rawModelId, metadataSnapshot);
const routing = resolveModelRouting(rawModelId, deps.definition);

// `hasImageInput` is computed from the flattened (pre-normalize) messages:
Expand Down Expand Up @@ -272,7 +278,7 @@ export async function prepareChatRequest(
const promptTokens = historyTrim.finalTokens;
const limits = modelLimits(metadata, settings, contextSizeOverride, promptTokens);

const thinkingPayload = thinkingProviderFor(rawModelId).buildPayload(settings.thinking, {
const thinkingPayload = thinkingProviderFor(rawModelId, metadata).buildPayload(settings.thinking, {
hasImageInput: hasImageInput && metadata.supportsVision,
endpoint: routing.endpointKind === "messages" ? "messages" : routing.endpointKind === "responses" ? "responses" : "chat",
});
Expand Down
Loading
Loading