diff --git a/ARCHITECTURE-MAP.md b/ARCHITECTURE-MAP.md index 7178331fd..caace8533 100644 --- a/ARCHITECTURE-MAP.md +++ b/ARCHITECTURE-MAP.md @@ -33,19 +33,19 @@ This is a **living reference** — update it in the same session whenever a modu Total `src/` ≈ **16,310 lines** across ~109 files (excl. tests). Grouped by domain (folders) then root-level utilities. -| Domain | Folder / Files | LoC | Owns | Key contracts | -| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| **Provider** | `src/provider/` — `OpenCodeProvider.ts` (532), `chatPrep.ts` (332), `messages.ts` (446), `modelInfo.ts` (239), `historyTrim.ts` (200), `settings.ts` (260), `modelList.ts` (185), `visionProxy.ts` (345), `providerDialogs.ts` (117), `transportLog.ts` (109), `definitions.ts` (238), `tokens.ts` (106), `providerUtils.ts` (35) | ~3,017 | The `OpenCodeProvider` class implementing `LanguageModelChatProvider` (thin — delegates to siblings via deps objects), request preparation (`chatPrep`: message conversion, vision proxy, history trimming, budgets, thinking payload), model-info assembly (`modelInfo`), model-list fetch/cache (`modelList`), Manage/Test-Connection flows (`providerDialogs`), rolling transport diagnostics (`transportLog`) | `prepareChatRequest()` / `provideModelChatInformation()` / `convertMessage()` / `normalizeMessages()` / `trimOldMessagesToFitContext()` / `historyByteCapForBudget()` / `ModelListFetcher` | -| **Transports** | `src/transports/` — `engine.ts` (409), `extractors.ts` (556), `extract.ts` (225), `thinkTags.ts` (139), `streamParts.ts` (88), `sse.ts` (31), `chatCompletions.ts` (64), `responses.ts` (30), `anthropic.ts` (28), `google.ts` (31) | ~1,601 | One adapter per wire format (OpenAI chat-completions, OpenAI Responses, Anthropic Messages, Google generateContent) + the shared streaming engine, SSE parser, response extractors | `streamOpenCodeResponse()` (engine), `OpenAiResponseExtractor` / `AnthropicResponseExtractor` | -| **Core (registry/routing)** | `src/core/` — `routing.ts` (546), `registry.ts` (142), `transport.ts` (70) | ~653 | Data-driven model registry (`MODEL_REGISTRY`), transport resolution (`resolveModelRouting`), Responses/Google SSE normalization, shared `StreamRequestOptions` contract | **Pure** — no `vscode` import, no side effects | -| **Models (metadata)** | `src/models/` — `metadata.ts` (523), `modelTables.ts` (141), `metadataFetcher.ts` (102), `modelLimits.ts` (52), `modelCapabilities.ts` (41), `modelNames.ts` (29), `pricing.ts` (88) | ~976 | models.dev live metadata + bundled fallback snapshot (static data tables in `modelTables.ts`), limit/capability resolution, pricing | Live fetch may fail → bundled snapshot MUST exist | -| **Usage** | `src/usage/` — `tracker.ts` (585, thin class shell), `trackerTypes.ts` (96), `trackerWindows.ts` (140), `trackerSummary.ts` (238), `dashboard.ts` (19 barrel) + `dashboard/` (`webview.ts`, `webviewData.ts`, `webviewHtml.ts`, `state.ts`, `statusBar.ts`, `targetEditor.ts`, `tooltip.ts` ≈ 1,405), `history.ts` (378), `usage.ts` (146), `goUsageSync.ts` (128), `formatting.ts` (129), `usageProfile.ts` (74), `pricing.ts` (62) | ~3,571 | Go usage tracker (types/windows/summary split out of the old god file), per-profile tracking, CLI SQLite history reader, server-usage sync, status bar + usage webview + quick-pick (webview split into state/status/webview modules) | Server meters authoritative for Session/Weekly/Monthly; device-local for Today/Yesterday | -| **Thinking** | `src/thinking/` — `provider.ts` (78), `base.ts` (75), `resolve.ts` (94), `deepseek.ts` (53), `glm.ts` (53), `kimi.ts` (81), `minimax.ts` (54), `mimo.ts` (72), `openai.ts` (57), `qwen.ts` (103), `fallback.ts` (39), `schema.ts` (106), `payload.ts` (26), `types.ts` (51) | ~942 | Per-family thinking strategy classes + config resolution (per-model config wins over workspace — but schema-default echoes are stripped first, `stripSchemaDefaultEcho` in `resolve.ts`, issue #226) | **Pure** — no `vscode` import; family from registry | -| **Request builders** | `src/request/` — `anthropic.ts` (233), `google.ts` (176), `types.ts` (117), `schema.ts` (94), `openai.ts` (95), `headers.ts` (110), `builders.ts` (17), `shared.ts` (41) | ~883 | Per-endpoint request-body builders + shared header builders (`x-opencode-session` / `x-opencode-request`) | `builders.ts` is the public barrel | -| **Commands** | `src/commands/` — `agentsWindow.ts` (130), `diagnostics.ts` (41), `providers.ts` (72), `thinkingPicker.ts` (31) | ~274 | Command handlers: diagnostics, agents-window BYOK bridge, provider enable/disable (state-aware toggle, base-vendor resolved — issue #228), thinking picker | Thin — delegates to provider/usage modules | -| **Autocomplete** | `src/autocomplete/` — `index.ts` (157), `engine.ts` (143), `provider.ts` (127), `context.ts` (89), `usage.ts` (88), `throttle.ts` (79), `prompt.ts` (60), `types.ts` (32) | ~775 | Inline code suggestions (opt-in) — FIM emulation over chat-completions, debounce/throttle, usage counters | Separate subsystem; not wired into Go cost tracker yet | -| **Extension entry** | `src/extension.ts` | 415 | Thin `activate()`/`deactivate()` — wiring only (command registration, provider registration, status bar init) | Target <300 lines; keep wiring-only | -| **Root utilities** | `src/config.ts` (278), `contextWindowHook.ts` (485), `contextWindowHookBridge.ts` (122), `errors.ts` (294), `retry.ts` (463), `utils.ts` (186), `responsesRequest.ts` (271), `toolCallAccumulator.ts` (187), `imageNormalizer.ts` (118), `visionProxyCache.ts` (79), `runtimeDiagnostics.ts` (57), `chatParts.ts` (55), `reasoningHistory.ts` (43), `tokenEstimate.ts` (39), `providerTypes.ts` (23), `openCodeAuth.ts` (20), `providerEnablement.ts` (18), `apiKeyResolution.ts` (8), `thinking.ts` (30, legacy barrel) | ~2,428 | Cross-cutting utilities (plus the two proposed-API `.d.ts` module augmentations ≈ 231 LoC — `chatProvider` v6 + `languageModelThinkingPart` v1) | `config.ts` must stay **dependency-free** | +| Domain | Folder / Files | LoC | Owns | Key contracts | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Provider** | `src/provider/` — `OpenCodeProvider.ts` (532), `chatPrep.ts` (332), `messages.ts` (446), `modelInfo.ts` (239), `historyTrim.ts` (200), `settings.ts` (260), `modelList.ts` (185), `visionProxy.ts` (345), `providerDialogs.ts` (117), `transportLog.ts` (109), `definitions.ts` (238), `tokens.ts` (106), `providerUtils.ts` (35) | ~3,017 | The `OpenCodeProvider` class implementing `LanguageModelChatProvider` (thin — delegates to siblings via deps objects), request preparation (`chatPrep`: message conversion, vision proxy, history trimming, budgets, thinking payload), model-info assembly (`modelInfo`), model-list fetch/cache (`modelList`), Manage/Test-Connection flows (`providerDialogs`), rolling transport diagnostics (`transportLog`) | `prepareChatRequest()` / `provideModelChatInformation()` / `convertMessage()` / `normalizeMessages()` / `trimOldMessagesToFitContext()` / `historyByteCapForBudget()` / `ModelListFetcher` | +| **Transports** | `src/transports/` — `engine.ts` (409), `extractors.ts` (556), `extract.ts` (225), `thinkTags.ts` (139), `streamParts.ts` (88), `sse.ts` (31), `chatCompletions.ts` (64), `responses.ts` (30), `anthropic.ts` (28), `google.ts` (31) | ~1,601 | One adapter per wire format (OpenAI chat-completions, OpenAI Responses, Anthropic Messages, Google generateContent) + the shared streaming engine, SSE parser, response extractors | `streamOpenCodeResponse()` (engine), `OpenAiResponseExtractor` / `AnthropicResponseExtractor` | +| **Core (registry/routing)** | `src/core/` — `routing.ts` (546), `registry.ts` (142), `transport.ts` (70) | ~653 | Data-driven model registry (`MODEL_REGISTRY`), transport resolution (`resolveModelRouting`), Responses/Google SSE normalization, shared `StreamRequestOptions` contract | **Pure** — no `vscode` import, no side effects | +| **Models (metadata)** | `src/models/` — `metadata.ts` (523), `modelTables.ts` (141), `metadataFetcher.ts` (102), `modelLimits.ts` (52), `modelCapabilities.ts` (41), `modelNames.ts` (29), `pricing.ts` (88) | ~976 | models.dev live metadata + bundled fallback snapshot (static data tables in `modelTables.ts`), limit/capability resolution, pricing | Live fetch may fail → bundled snapshot MUST exist | +| **Usage** | `src/usage/` — `tracker.ts` (585, thin class shell), `trackerTypes.ts` (96), `trackerWindows.ts` (140), `trackerSummary.ts` (238), `dashboard.ts` (19 barrel) + `dashboard/` (`webview.ts`, `webviewData.ts`, `webviewHtml.ts`, `state.ts`, `statusBar.ts`, `targetEditor.ts`, `tooltip.ts` ≈ 1,405), `history.ts` (378), `usage.ts` (146), `goUsageSync.ts` (128), `formatting.ts` (129), `usageProfile.ts` (74), `pricing.ts` (62) | ~3,571 | Go usage tracker (types/windows/summary split out of the old god file), per-profile tracking, CLI SQLite history reader, server-usage sync, status bar + usage webview + quick-pick (webview split into state/status/webview modules) | Server meters authoritative for Session/Weekly/Monthly; device-local for Today/Yesterday | +| **Thinking** | `src/thinking/` — `provider.ts` (81), `base.ts` (75), `resolve.ts` (119), `deepseek.ts` (53), `glm.ts` (93), `kimi.ts` (81), `minimax.ts` (54), `muse.ts` (71), `mimo.ts` (78), `openai.ts` (57), `qwen.ts` (103), `fallback.ts` (93), `schema.ts` (124), `payload.ts` (26), `types.ts` (61) | ~1169 | Per-family thinking strategy classes + config resolution (per-model config wins over workspace — but schema-default echoes are stripped first, `stripSchemaDefaultEcho` in `resolve.ts`, issue #226). `FallbackThinking` is the generic data-driven path: models.dev `reasoning` / `reasoning_options` decide whether an unknown model gets a picker and a plain `reasoning_effort`, with no per-model hardcoding (issue #252) | **Pure** — no `vscode` import; family from registry | +| **Request builders** | `src/request/` — `anthropic.ts` (233), `google.ts` (176), `types.ts` (117), `schema.ts` (94), `openai.ts` (95), `headers.ts` (110), `builders.ts` (17), `shared.ts` (41) | ~883 | Per-endpoint request-body builders + shared header builders (`x-opencode-session` / `x-opencode-request`) | `builders.ts` is the public barrel | +| **Commands** | `src/commands/` — `agentsWindow.ts` (130), `diagnostics.ts` (41), `providers.ts` (72), `thinkingPicker.ts` (31) | ~274 | Command handlers: diagnostics, agents-window BYOK bridge, provider enable/disable (state-aware toggle, base-vendor resolved — issue #228), thinking picker | Thin — delegates to provider/usage modules | +| **Autocomplete** | `src/autocomplete/` — `index.ts` (157), `engine.ts` (143), `provider.ts` (127), `context.ts` (89), `usage.ts` (88), `throttle.ts` (79), `prompt.ts` (60), `types.ts` (32) | ~775 | Inline code suggestions (opt-in) — FIM emulation over chat-completions, debounce/throttle, usage counters | Separate subsystem; not wired into Go cost tracker yet | +| **Extension entry** | `src/extension.ts` | 415 | Thin `activate()`/`deactivate()` — wiring only (command registration, provider registration, status bar init) | Target <300 lines; keep wiring-only | +| **Root utilities** | `src/config.ts` (278), `contextWindowHook.ts` (485), `contextWindowHookBridge.ts` (122), `errors.ts` (294), `retry.ts` (463), `utils.ts` (186), `responsesRequest.ts` (271), `toolCallAccumulator.ts` (187), `imageNormalizer.ts` (118), `visionProxyCache.ts` (79), `runtimeDiagnostics.ts` (57), `chatParts.ts` (55), `reasoningHistory.ts` (43), `tokenEstimate.ts` (39), `providerTypes.ts` (23), `openCodeAuth.ts` (20), `providerEnablement.ts` (18), `apiKeyResolution.ts` (8), `thinking.ts` (30, legacy barrel) | ~2,428 | Cross-cutting utilities (plus the two proposed-API `.d.ts` module augmentations ≈ 231 LoC — `chatProvider` v6 + `languageModelThinkingPart` v1) | `config.ts` must stay **dependency-free** | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 7177aa2cc..50e867228 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index bf3e74d9b..b5fedc442 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/issues/109-20261004-issue252-generic-reasoning-effort.md b/docs/issues/109-20261004-issue252-generic-reasoning-effort.md new file mode 100644 index 000000000..7590b5e19 --- /dev/null +++ b/docs/issues/109-20261004-issue252-generic-reasoning-effort.md @@ -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 { + 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. diff --git a/package.json b/package.json index e337b2336..537c3cbbc 100644 --- a/package.json +++ b/package.json @@ -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." } } }, diff --git a/src/commands/thinkingPicker.ts b/src/commands/thinkingPicker.ts index 2ab530562..41edbf341 100644 --- a/src/commands/thinkingPicker.ts +++ b/src/commands/thinkingPicker.ts @@ -16,6 +16,7 @@ export async function showThinkingEffortPicker(): Promise { { 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( diff --git a/src/config.ts b/src/config.ts index 9e64d42b3..01ee28f5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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) ───────────────────────────────────────── @@ -398,4 +399,5 @@ export const THINKING_DEFAULTS = { qwenBudget: "auto", mimo: "off", muse: "off", + generic: "off", } as const; diff --git a/src/provider/chatPrep.ts b/src/provider/chatPrep.ts index 9c6d71a7b..22cb5987e 100644 --- a/src/provider/chatPrep.ts +++ b/src/provider/chatPrep.ts @@ -123,14 +123,24 @@ 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, }); @@ -138,10 +148,6 @@ export async function prepareChatRequest( ...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: @@ -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", }); diff --git a/src/provider/settings.ts b/src/provider/settings.ts index ed87e4826..0f8c43016 100644 --- a/src/provider/settings.ts +++ b/src/provider/settings.ts @@ -11,6 +11,7 @@ import { SETTING_STRIP_THINK_TAGS, SETTING_TEMPERATURE, SETTING_THINKING_DEEPSEEK, + SETTING_THINKING_GENERIC, SETTING_THINKING_GLM, SETTING_THINKING_KIMI, SETTING_THINKING_MIMO, @@ -43,6 +44,7 @@ export const THINKING_ALLOWED_VALUES = { qwenBudget: ["auto", "4096", "16384", "32768", "81920"], mimo: ["off", "low", "medium", "high"], muse: ["off", "low", "medium", "high", "xhigh"], + generic: ["off", "low", "medium", "high", "xhigh", "max"], } as const; /** Return `value` when it is one of `allowed`, else `fallback`. */ @@ -195,6 +197,11 @@ export function getSettings(): ApiSettings { THINKING_ALLOWED_VALUES.muse, THINKING_DEFAULTS.muse, ), + generic: validThinkingValue( + readConfigValue(config, SETTING_THINKING_GENERIC, THINKING_DEFAULTS.generic), + THINKING_ALLOWED_VALUES.generic, + THINKING_DEFAULTS.generic, + ), }, stripThinkTags: config.get(SETTING_STRIP_THINK_TAGS, "auto"), }; diff --git a/src/request/anthropic.ts b/src/request/anthropic.ts index 593bffae8..7764e7bf5 100644 --- a/src/request/anthropic.ts +++ b/src/request/anthropic.ts @@ -36,7 +36,7 @@ export function buildAnthropicMessagesRequestBody( const tools = mapAnthropicTools(options.tools); // The per-provider strategy picks the Anthropic-native thinking shape for // Qwen on this endpoint ({ type: "enabled"|"disabled" }, budget_tokens). - const thinkingPayload = thinkingProviderFor(modelId).buildPayload(settings.thinking, { + const thinkingPayload = thinkingProviderFor(modelId, metadata).buildPayload(settings.thinking, { hasImageInput: messagesHaveImages(messages), endpoint: "messages", }); diff --git a/src/request/openai.ts b/src/request/openai.ts index 1eaff96a9..1b74e4018 100644 --- a/src/request/openai.ts +++ b/src/request/openai.ts @@ -27,7 +27,7 @@ export function buildChatCompletionsRequestBody( limits: ModelLimits, ): Record { const tools = mapOpenAiTools(options.tools); - const thinkingPayload = thinkingProviderFor(modelId).buildPayload(settings.thinking, { + const thinkingPayload = thinkingProviderFor(modelId, metadata).buildPayload(settings.thinking, { hasImageInput: messagesHaveImages(messages), endpoint: "chat", }); @@ -62,7 +62,7 @@ export function buildResponsesRequestBody( // the whole request otherwise. const input = pairResponsesFunctionCallItems(messages.flatMap((message) => responsesInputItemsFromMessage(message))); const tools = mapResponsesTools(options.tools, modelId); - const thinkingPayload = thinkingProviderFor(modelId).buildPayload(settings.thinking, { + const thinkingPayload = thinkingProviderFor(modelId, metadata).buildPayload(settings.thinking, { hasImageInput: messagesHaveImages(messages), endpoint: "responses", }); diff --git a/src/test/thinking.test.ts b/src/test/thinking.test.ts index 57dc74003..97a211f7b 100644 --- a/src/test/thinking.test.ts +++ b/src/test/thinking.test.ts @@ -22,6 +22,7 @@ const defaultSettings: ThinkingSettings = { qwenBudget: "auto", mimo: "off", muse: "off", + generic: "off", }; /** Minimal reasoning-capable metadata used for schema tests. */ @@ -37,6 +38,33 @@ const reasoningMetadata = { source: "models.dev" as const, }; +/** + * A model that reports reasoning support but declares no `reasoning_options` — + * the "new model this build has never heard of" case (e.g. `space-bunny-free`). + */ +const genericReasoningMetadata = { + reasoning: true, + contextWindow: 202752, + maxOutputTokens: 32768, + supportsVision: false, + supportsAudio: false, + supportsVideo: false, + supportsPdf: false, + source: "models.dev" as const, +}; + +/** A model that reports NO reasoning support. */ +const nonReasoningMetadata = { + reasoning: false, + contextWindow: 202752, + maxOutputTokens: 32768, + supportsVision: false, + supportsAudio: false, + supportsVideo: false, + supportsPdf: false, + source: "models.dev" as const, +}; + /** * Kimi K2.7-code thinking fix (issue #25): * the payload must always emit { thinking: { type: "enabled", keep: "all" } } @@ -61,6 +89,7 @@ describe("KimiThinking — kimi-k2.7-code (issue #25)", () => { it("forces kimi='on' through resolve even when override requests 'off'", () => { const resolved = resolveThinkingConfig({ modelId: "kimi-k2.7-code", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "off" }, }); @@ -68,7 +97,7 @@ describe("KimiThinking — kimi-k2.7-code (issue #25)", () => { }); it("forces kimi='on' even with no override at all (defensive against stale cache)", () => { - const resolved = resolveThinkingConfig({ modelId: "kimi-k2.7-code", workspace: defaultSettings }); + const resolved = resolveThinkingConfig({ modelId: "kimi-k2.7-code", metadata: genericReasoningMetadata, workspace: defaultSettings }); assert.equal(resolved.settings.kimi, "on"); }); }); @@ -92,6 +121,7 @@ describe("KimiThinking — other kimi models", () => { it("respects 'off' override", () => { const resolved = resolveThinkingConfig({ modelId: "kimi-k2.6", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "off" }, }); @@ -101,6 +131,7 @@ describe("KimiThinking — other kimi models", () => { it("respects 'on' override", () => { const resolved = resolveThinkingConfig({ modelId: "kimi-k2.6", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "on" }, }); @@ -193,6 +224,190 @@ describe("FallbackThinking — generic payload (issue #207)", () => { }); }); +/** + * Generic reasoning support for models released after this build: the + * models.dev `reasoning` flag alone decides whether a model gets a picker and + * a `reasoning_effort`, with no per-family code involved. + */ +describe("FallbackThinking — unknown reasoning-capable models", () => { + it("gets the generic off→max schema when it declares no reasoning_options", () => { + const schema = thinkingProviderFor("space-bunny-free", genericReasoningMetadata).schema(genericReasoningMetadata); + assert.ok(schema, "expected a generic reasoning schema"); + const reasoningEffort = schema.properties.reasoningEffort as Record; + assert.deepEqual(reasoningEffort.enum, ["off", "low", "medium", "high", "xhigh", "max"]); + assert.deepEqual(reasoningEffort.enumItemLabels, ["Off", "Low", "Medium", "High", "XHigh", "Max"]); + assert.equal(reasoningEffort.default, "off"); + }); + + it("the generic schema default tracks THINKING_DEFAULTS.generic", () => { + const schema = thinkingProviderFor("space-bunny-free", genericReasoningMetadata).schema(genericReasoningMetadata); + assert.ok(schema); + const reasoningEffort = schema.properties.reasoningEffort as Record; + assert.equal(reasoningEffort.default, THINKING_DEFAULTS.generic); + }); + + it("emits a plain OpenAI-compatible reasoning_effort for every non-off level", () => { + const provider = thinkingProviderFor("space-bunny-free", genericReasoningMetadata); + for (const level of ["low", "medium", "high", "xhigh", "max"]) { + const payload = provider.buildPayload({ ...defaultSettings, generic: level }); + assert.deepEqual(payload, { reasoning_effort: level }, `expected ${level} to be emitted`); + } + }); + + it("'off' — the default — emits nothing and reports no thinking request", () => { + const provider = thinkingProviderFor("space-bunny-free", genericReasoningMetadata); + assert.deepEqual(provider.buildPayload(defaultSettings), {}); + assert.equal(provider.requestsThinking(defaultSettings), false); + assert.equal(provider.requestsThinking({ ...defaultSettings, generic: "high" }), true); + }); + + it("carries a picker pick all the way to the request body", () => { + // The full production path: VS Code delivers the pick in modelConfiguration, + // resolveThinkingConfig maps it onto the family settings, and the request + // builder spreads buildPayload into the body. + const resolved = resolveThinkingConfig({ + modelId: "space-bunny-free", + metadata: genericReasoningMetadata, + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "max" }, + }); + assert.equal(resolved.source, "modelConfiguration"); + const payload = thinkingProviderFor("space-bunny-free", genericReasoningMetadata).buildPayload(resolved.settings); + assert.deepEqual(payload, { reasoning_effort: "max" }); + }); + + it("strips the schema-default 'off' echo so the workspace setting still wins (issue #226)", () => { + // Depends on `metadata` reaching resolveThinkingConfig: without it the + // picker schema is unknown, no default is stripped, and VS Code's "off" + // baseline would clobber the user's workspace pick. + const resolved = resolveThinkingConfig({ + modelId: "space-bunny-free", + metadata: genericReasoningMetadata, + workspace: { ...defaultSettings, generic: "high" }, + modelConfiguration: { reasoningEffort: "off" }, + }); + assert.equal(resolved.settings.generic, "high"); + assert.equal(resolved.source, "workspace"); + }); + + it("prefers the model's own models.dev reasoning_options when it declares them", () => { + const schema = thinkingProviderFor("hy4", reasoningMetadata).schema(reasoningMetadata); + assert.ok(schema); + const reasoningEffort = schema.properties.reasoningEffort as Record; + assert.deepEqual(reasoningEffort.enum, ["off", "high", "max"]); + }); + + it("applies an override for its own picker levels, not just the generic ladder", () => { + const resolved = resolveThinkingConfig({ + modelId: "hy4", + metadata: reasoningMetadata, + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "max" }, + }); + assert.equal(resolved.settings.generic, "max"); + assert.equal(resolved.source, "modelConfiguration"); + }); + + it("keeps the repetition penalty alongside the effort for penalised models", () => { + const provider = thinkingProviderFor("big-pickle", genericReasoningMetadata); + assert.deepEqual(provider.buildPayload(defaultSettings), { repetition_penalty: 1.2 }); + assert.deepEqual(provider.buildPayload({ ...defaultSettings, generic: "high" }), { + repetition_penalty: 1.2, + reasoning_effort: "high", + }); + }); +}); + +describe("FallbackThinking — unknown models without reasoning support", () => { + it("gets no schema at all, so no picker is shown", () => { + assert.equal(thinkingProviderFor("hy3", nonReasoningMetadata).schema(nonReasoningMetadata), undefined); + }); + + it("emits no reasoning payload even if a pick is somehow delivered", () => { + const provider = thinkingProviderFor("hy3", nonReasoningMetadata); + assert.deepEqual(provider.buildPayload({ ...defaultSettings, generic: "high" }), {}); + assert.equal(provider.requestsThinking({ ...defaultSettings, generic: "high" }), false); + }); + + it("a model with no metadata at all stays inert (metadata unknown, not assumed capable)", () => { + const provider = thinkingProviderFor("hy3"); + assert.equal(provider.schema(), undefined); + assert.deepEqual(provider.buildPayload({ ...defaultSettings, generic: "high" }), {}); + }); +}); + +describe("FallbackThinking — family-less models on non-chat endpoints stay inert", () => { + // `reasoning_effort` means nothing on these wire formats, so a picker there + // would be a control that silently does nothing (or 400s the request). + it("claude-*, gemini-* and grok-* get neither picker nor payload", () => { + for (const id of ["claude-sonnet-4.6", "gemini-3.5-pro", "grok-4.6"]) { + const provider = thinkingProviderFor(id, genericReasoningMetadata); + assert.equal(provider.schema(genericReasoningMetadata), undefined, `expected no picker for ${id}`); + assert.deepEqual(provider.buildPayload({ ...defaultSettings, generic: "high" }), {}, `expected no payload for ${id}`); + } + }); +}); + +describe("known families keep their own strategy (generic setting must not leak)", () => { + it("each known family still builds its family-specific payload shape", () => { + const cases: Array<[string, ThinkingSettings, Record]> = [ + ["deepseek-v4-pro", { ...defaultSettings, deepseek: "high" }, { reasoning_effort: "high" }], + ["glm-5.2", { ...defaultSettings, glm: "max" }, { reasoning_effort: "max" }], + ["kimi-k2.6", { ...defaultSettings, kimi: "on" }, { thinking: { type: "enabled" } }], + ["minimax-m3", { ...defaultSettings, minimax: "on" }, { thinking: { type: "adaptive" } }], + ["mimo-v2.5", { ...defaultSettings, mimo: "medium" }, { reasoning_effort: "medium", budget_tokens: 16384, repetition_penalty: 1.2 }], + ["qwen3.6-plus", { ...defaultSettings, qwen: "on" }, { enable_thinking: true }], + ["gpt-5.6-luna", { ...defaultSettings, openai: "high" }, { reasoning: { effort: "high" } }], + ["muse-spark-1.2-contributor", { ...defaultSettings, muse: "high" }, { reasoning: { effort: "high" } }], + ]; + for (const [modelId, settings, expected] of cases) { + assert.deepEqual(thinkingProviderFor(modelId).buildPayload(settings), expected, `payload changed for ${modelId}`); + } + }); + + it("the generic setting never reaches a known family's payload", () => { + for (const modelId of [ + "deepseek-v4-pro", + "glm-5.2", + "kimi-k2.6", + "minimax-m3", + "mimo-v2.5", + "qwen3.6-plus", + "gpt-5.6-luna", + "muse-spark-1.2-contributor", + ]) { + // Same body with and without a generic pick — the family keeps its own shape. + const provider = thinkingProviderFor(modelId, genericReasoningMetadata); + assert.deepEqual( + provider.buildPayload({ ...defaultSettings, generic: "high" }), + provider.buildPayload(defaultSettings), + `${modelId} must ignore the generic effort`, + ); + } + }); + + it("a known family's override still maps onto its own field", () => { + const resolved = resolveThinkingConfig({ + modelId: "deepseek-v4-pro", + metadata: genericReasoningMetadata, + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "max" }, + }); + assert.equal(resolved.settings.deepseek, "max"); + assert.equal(resolved.settings.generic, "off"); + }); + + it("ignores a delivered pick for a model metadata says cannot reason", () => { + const resolved = resolveThinkingConfig({ + modelId: "hy3", + metadata: nonReasoningMetadata, + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "high" }, + }); + assert.equal(resolved.settings.generic, "off"); + }); +}); + describe("MimoThinking — payload + display (native reasoning model)", () => { it("mimo 'off' emits only repetition_penalty", () => { const payload = thinkingProviderFor("mimo-v2.5").buildPayload({ ...defaultSettings, mimo: "off" }); @@ -262,6 +477,7 @@ describe("GLMThinking — override (issue #61)", () => { for (const value of ["high", "max", "off"]) { const resolved = resolveThinkingConfig({ modelId: "glm-5.2", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: value }, }); @@ -273,6 +489,7 @@ describe("GLMThinking — override (issue #61)", () => { for (const value of ["on", "medium"]) { const resolved = resolveThinkingConfig({ modelId: "glm-5.2", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: value }, }); @@ -302,13 +519,14 @@ 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 }); + const resolved = resolveThinkingConfig({ modelId: "glm-5.3", metadata: genericReasoningMetadata, 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", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "off" }, }); @@ -318,6 +536,7 @@ describe("GLMThinking — glm-5.3 always-thinking (issue #162)", () => { it("respects a valid 'max' override for glm-5.3", () => { const resolved = resolveThinkingConfig({ modelId: "glm-5.3", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "max" }, }); @@ -345,7 +564,7 @@ describe("GLMThinking — glm-5.3 always-thinking (issue #162)", () => { 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 }); + const resolved = resolveThinkingConfig({ modelId: id, metadata: genericReasoningMetadata, workspace: defaultSettings }); assert.equal(resolved.settings.glm, "high", `expected ${id} to force thinking on`); } }); @@ -429,6 +648,7 @@ describe("resolveThinkingConfig — provenance & priority", () => { it("modelConfiguration wins over the workspace default", () => { const resolved = resolveThinkingConfig({ modelId: "deepseek-v4-pro", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "max" }, }); @@ -438,7 +658,7 @@ describe("resolveThinkingConfig — provenance & priority", () => { }); it("falls back to workspace when no override exists", () => { - const resolved = resolveThinkingConfig({ modelId: "deepseek-v4-pro", workspace: defaultSettings }); + const resolved = resolveThinkingConfig({ modelId: "deepseek-v4-pro", metadata: genericReasoningMetadata, workspace: defaultSettings }); assert.equal(resolved.settings.deepseek, "off"); assert.equal(resolved.source, "workspace"); assert.equal(resolved.overrideApplied, false); @@ -447,6 +667,7 @@ describe("resolveThinkingConfig — provenance & priority", () => { it("a delivered schema-default echo is ignored so the workspace default wins (issue #226)", () => { const resolved = resolveThinkingConfig({ modelId: "deepseek-v4-pro", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "off" }, }); @@ -458,6 +679,7 @@ describe("resolveThinkingConfig — provenance & priority", () => { it("a schema-default echo does not mask a non-default workspace setting (issue #226)", () => { const resolved = resolveThinkingConfig({ modelId: "deepseek-v4-pro", + metadata: genericReasoningMetadata, workspace: { ...defaultSettings, deepseek: "high" }, modelConfiguration: { reasoningEffort: "off" }, }); @@ -469,6 +691,7 @@ describe("resolveThinkingConfig — provenance & priority", () => { it("echo stripping is per-key: a real choice beside an echo still applies", () => { const resolved = resolveThinkingConfig({ modelId: "qwen3.6-plus", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "off", thinkingBudget: "16384" }, }); @@ -479,6 +702,7 @@ describe("resolveThinkingConfig — provenance & priority", () => { it("models whose schema default is not 'off' strip their own default too (kimi-k2.7-code)", () => { const resolved = resolveThinkingConfig({ modelId: "kimi-k2.7-code", + metadata: genericReasoningMetadata, workspace: defaultSettings, modelConfiguration: { reasoningEffort: "on" }, }); diff --git a/src/thinking.ts b/src/thinking.ts index 0b9dda2f5..3a91cb908 100644 --- a/src/thinking.ts +++ b/src/thinking.ts @@ -8,7 +8,7 @@ export { thinkingFamily, thinkingProviderFor } from "./thinking/provider"; export type { ThinkingProvider } from "./thinking/provider"; export { resolveThinkingConfig, extractThinkingOverride, stripSchemaDefaultEcho } from "./thinking/resolve"; export type { ResolveThinkingConfigInput } from "./thinking/resolve"; -export { schemaFromReasoningOptions, genericReasoningSchema } from "./thinking/schema"; +export { schemaFromReasoningOptions, genericReasoningSchema, GENERIC_REASONING_EFFORTS } from "./thinking/schema"; export type { ThinkingSchema } from "./thinking/schema"; export { bodyRequestsThinking } from "./thinking/payload"; export { DeepSeekThinking } from "./thinking/deepseek"; diff --git a/src/thinking/fallback.ts b/src/thinking/fallback.ts index 15efb4060..e804e8b94 100644 --- a/src/thinking/fallback.ts +++ b/src/thinking/fallback.ts @@ -1,12 +1,29 @@ /** - * Fallback thinking strategy for models with no known family. + * Fallback thinking strategy for models with no known thinking family. + * + * This is the generic, data-driven path: whatever models.dev says about a + * model wins, so a model released after this build still gets a working + * reasoning control instead of silently emitting nothing. + * + * - `metadata.reasoning` true → the model gets a generic reasoning-effort + * picker (its own `reasoning_options` levels when it declares them, + * otherwise the shared off→max ladder) and the pick is sent as a plain + * OpenAI-compatible `reasoning_effort`. + * - `metadata.reasoning` false/absent → no picker and no reasoning field. + * + * The generic payload is only valid on the OpenAI-compatible chat-completions + * endpoint. Family-less models routed elsewhere — `claude-*` to Anthropic + * messages, `gemini-*` to Google, `grok-*` to the Responses API — keep their + * existing no-op behaviour: `reasoning_effort` means nothing on those wire + * formats, so surfacing a picker there would be a control that silently does + * nothing (or gets the request rejected). `endpointKind` comes from the shared + * model registry, so this stays data-driven rather than a name list. * - * Only reasoning-capable models (`metadata.reasoning`) get a generic off/on - * picker schema; no thinking fields are ever emitted to the request, and * `reasoning_content` is always treated as genuine CoT (never visible text). */ import { BaseThinkingProvider } from "./base"; -import { schemaFromReasoningOptions, genericReasoningSchema, type ThinkingSchema } from "./schema"; +import { schemaFromReasoningOptions, genericReasoningSchema, GENERIC_REASONING_EFFORTS, type ThinkingSchema } from "./schema"; +import { lookupModelRegistryEntry } from "../core/registry"; import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; @@ -33,21 +50,44 @@ export class FallbackThinking extends BaseThinkingProvider { schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { const effective = this.metadata ?? metadata; - return schemaFromReasoningOptions(effective) ?? (effective?.reasoning ? genericReasoningSchema() : undefined); + if (!this.genericEffortSupported(effective)) return undefined; + return schemaFromReasoningOptions(effective) ?? genericReasoningSchema(); } - // applyOverride: no known family → no override mapping (inherited default). + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + if (!this.genericEffortSupported()) return settings; + // Accept exactly what this model's own picker offered, so a models.dev + // level outside the generic ladder (`max`, `on`, …) still reaches the wire. + return this.applyEffort(settings, override, "generic", this.effortValues()); + } - buildPayload(_thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { - if (REPETITION_PENALIZED_MODEL_PATTERNS.some((pattern) => pattern.test(this.modelId))) { - return { repetition_penalty: GENERIC_REPETITION_PENALTY }; - } - return {}; + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + const base: Record = REPETITION_PENALIZED_MODEL_PATTERNS.some((pattern) => pattern.test(this.modelId)) + ? { repetition_penalty: GENERIC_REPETITION_PENALTY } + : {}; + if (!this.genericEffortSupported() || thinking.generic === "off") return base; + return { ...base, reasoning_effort: thinking.generic }; } - requestsThinking(_thinking: ThinkingSettings): boolean { - return false; + requestsThinking(thinking: ThinkingSettings): boolean { + return this.genericEffortSupported() && thinking.generic !== "off"; } // treatReasoningAsContent: reasoning is genuine CoT → default false. + + /** + * Whether this model takes the generic OpenAI-compatible `reasoning_effort`: + * it must declare reasoning support AND be served over chat-completions. + * The single predicate behind the schema, the override mapping and the + * payload, so the picker can never offer a control the request ignores. + */ + private genericEffortSupported(metadata = this.metadata): boolean { + return metadata?.reasoning === true && lookupModelRegistryEntry(this.modelId).endpointKind === "chat-completions"; + } + + /** This model's offered levels, falling back to the shared generic ladder. */ + private effortValues(): readonly string[] { + const offered = (this.schema()?.properties.reasoningEffort as { enum?: unknown } | undefined)?.enum; + return Array.isArray(offered) ? offered.filter((value): value is string => typeof value === "string") : GENERIC_REASONING_EFFORTS; + } } diff --git a/src/thinking/resolve.ts b/src/thinking/resolve.ts index 85d7c2ef7..4efbb50a2 100644 --- a/src/thinking/resolve.ts +++ b/src/thinking/resolve.ts @@ -18,7 +18,14 @@ import { thinkingProviderFor } from "./provider"; export interface ResolveThinkingConfigInput { modelId: string; - metadata?: ResolvedModelMetadata; + /** + * REQUIRED. The generic (no-known-family) strategy decides whether a model + * takes a reasoning effort purely from `metadata.reasoning`, and the picker's + * schema default comes from the same metadata — so resolving without it + * silently discards the user's per-model pick and re-introduces the stale + * schema-default echo that {@link stripSchemaDefaultEcho} exists to remove. + */ + metadata: ResolvedModelMetadata; /** Workspace settings (`opencodego.thinking.*`), already defaulted. */ workspace: ThinkingSettings; /** Live per-model config from `options.modelConfiguration` (may be absent). */ diff --git a/src/thinking/schema.ts b/src/thinking/schema.ts index 59a20930c..77396ca68 100644 --- a/src/thinking/schema.ts +++ b/src/thinking/schema.ts @@ -93,14 +93,30 @@ export function schemaFromReasoningOptions(metadata?: ResolvedModelMetadata): Th }; } -/** Generic off/on schema for any reasoning-capable model (Priority 3 fallback). */ +/** + * The effort ladder offered to a reasoning-capable model that declares no + * `reasoning_options` in models.dev. Kept small and OpenAI-compatible so it is + * safe for any future model on the chat-completions endpoint; a model that + * declares its own levels gets those instead (see + * {@link schemaFromReasoningOptions}, which takes priority). + */ +export const GENERIC_REASONING_EFFORTS = ["off", "low", "medium", "high", "xhigh", "max"] as const; + +/** Generic effort schema for any reasoning-capable model (Priority 3 fallback). */ export function genericReasoningSchema(): ThinkingSchema { return { properties: { reasoningEffort: effortProperty({ - enum: ["off", "on"], - labels: ["Off", "On"], - descriptions: ["Fastest responses", "Enable reasoning"], + enum: GENERIC_REASONING_EFFORTS, + labels: ["Off", "Low", "Medium", "High", "XHigh", "Max"], + descriptions: [ + "Fastest responses", + "Faster responses with less reasoning", + "Balanced reasoning and speed", + "Greater reasoning depth but slower", + "Maximum reasoning depth", + "Maximum reasoning effort", + ], default: "off", }), }, diff --git a/src/thinking/types.ts b/src/thinking/types.ts index 5aec8777f..79e7d6546 100644 --- a/src/thinking/types.ts +++ b/src/thinking/types.ts @@ -15,6 +15,15 @@ export interface ThinkingSettings { qwenBudget: "auto" | "4096" | "16384" | "32768" | "81920"; mimo: "off" | "low" | "medium" | "high"; muse: "off" | "low" | "medium" | "high" | "xhigh"; + /** + * Effort for models with no dedicated thinking family, sent as a plain + * OpenAI-compatible `reasoning_effort`. `"off"` sends nothing. + * + * Deliberately open-ended rather than a fixed union: the picker's levels come + * from the model's own models.dev `reasoning_options` when it declares any, + * so a model can offer levels this build has never heard of. + */ + generic: string; } /** Detected thinking family for a raw model id. `null` = no known family. */