diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a23b0f..046cda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,20 @@ _No unreleased changes yet._ ### Added +- **`[Thinking]` `budget_tokens` cap for MiMo thinking payload (#36).** MiMo 2.5 / 2.5-Pro reasoning can loop indefinitely when `reasoning_effort` is set without a token budget. Each effort level now sends `budget_tokens`: `low` → 8,192, `medium` → 16,384, `high` → 32,768. Graceful degradation via `retry.ts` handler for HTTP 400 `"budget_tokens"` rejection. +- **`[Streaming]` Go gateway `reasoning_content` workaround (#36, #37635).** The opencode-go gateway places ALL streaming response text inside `reasoning_content` instead of `content` (upstream bug [#37635](https://github.com/anomalyco/opencode/issues/37635)). When a Go-gateway request has NO `reasoning_effort` in the body (MiMo thinking OFF), `reasoning_content` is emitted as visible text instead of a thinking part. When `reasoning_effort` IS present (thinking ON), CoT correctly stays in the thinking panel. Zen gateway and all other providers unaffected. Can be removed once upstream #37635 is fixed. +- **`[Streaming]` Suffix-repetition loop detection (#36).** `OpenAiResponseExtractor.handleReasoning()` now tracks 40-char suffix across consecutive reasoning chunks. If the same suffix repeats 6+ times, the model is stuck in a word-level loop — thinking parts are suppressed and a visible warning `[Reasoning loop detected — thinking output suppressed]` is emitted instead. - **`[Commands]` Top-level `Refresh Models` commands for Go and Zen (#78).** The `Refresh Models` action was previously buried inside the `OpenCode Go: Manage Provider` QuickPick, and Zen had no manual refresh path at all. Two new commands are now registered: `OpenCode Go: Refresh Models` and `OpenCode Zen: Refresh Models`, each bypassing the Manage menu and going straight to a model-list fetch. For parity, `OpenCode Zen: Manage Provider` is also added, matching the existing Go command. The new commands are especially useful when the picker is showing a stale or bundled list at startup (issue #78) and you want to force a re-fetch without opening the Manage menu. - **`[Vision]` Multimodal tool results — MCP screenshot forwarding (#77).** Images returned inside a `LanguageModelToolResultPart` (e.g. screenshots from `chrome-devtools-mcp`, `playwright-mcp`) are now forwarded to vision-capable models. Previously these images were silently dropped by the serialization layer and the model would report "I cannot see the image". Images are encoded as OpenAI-style `image_url` content parts and translated into the native multimodal shape for each transport: chat-completions (native array content), Anthropic messages (`tool_result.content: AnthropicContentBlock[]`), Google Gemini (`functionResponse.response.parts: [{inlineData}]`). Oversized images (>1 MB raw bytes) are replaced with an actionable placeholder note so a single full-page MCP screenshot can't push the request payload past the upstream limit. The Responses API cannot carry images in tool output and degrades to a placeholder note on that transport only. ### Fixed +- **`[Streaming]` Regressive visible-text suppression removed.** Previous guards (`contentAfterReasoning`, `shouldSuppressTextEmit`) incorrectly blocked visible text emission for ALL reasoning models. These models naturally produce `reasoning_content` first then `content` — this is normal, not degradation. Both guards removed; only suffix-repetition loop detection remains active. +- **`[Streaming]` Go gateway reasoning leak fix (#36, #37635).** MiMo 2.5 responses were leaking thinking content into the visible chat when thinking was OFF. The `treatReasoningAsContent` workaround now only activates when ALL conditions are met: (1) request URL includes `/zen/go/`, (2) `reasoning_effort` is NOT in the request body, (3) `delta.content` is empty. This ensures the workaround applies only to the MiMo-thinking-OFF scenario while leaving all other models untouched. +- **`[Logging]` Model registration log spam during UI refresh.** VS Code refreshes model info on roughly a 300 ms cadence during chat UI activity; each call previously produced one log line per registered model (22+ lines per call). `provideLanguageModelChatInformation` now emits a single summary line per invocation (`Models registered: count=N provider=… first=… last=…`). Output channel is dramatically cleaner during testing. +- **`[Logging]` Transient model-list fetch failures no longer pop a modal warning.** OpenCode's shared gateway occasionally returns transient 400/503 responses that resolve on retry within seconds, and the previous behavior called `showWarningMessage` on every failure — including from auto-registered provider variants the user may not actively use (e.g. `OpenCode Zen (Agents)`). Failures now log to the Output channel only; the bundled `fallbackModels` snapshot keeps the picker functional. +- **`[Resilience]` Model-list fetch now tolerates transient network failures (#78).** On flaky networks (and especially on VS Code 1.129 where the new agent host raises the rate of concurrent `provideLanguageModelChatInformation` calls), a single `TypeError: fetch failed` at startup — DNS wobble, TCP reset, undici socket reuse race ([`nodejs/undici#5450`](https://github.com/nodejs/undici/issues/5450)) — caused the picker to drop to the bundled list or empty out entirely ("flash then disappear"). `fetchModels()` now (1) wraps each attempt in `AbortSignal.timeout(15_000)` so a hung connect can't stall the picker for the full undici default of 5 minutes, (2) retries up to 3 times with exponential backoff (500 ms / 1 s / 2 s) on transient errors only — `ECONNRESET`, `EAI_AGAIN`, `UND_ERR_CONNECT_TIMEOUT`, HTTP 408/429/5xx, and the generic `TypeError: fetch failed` wrapper — never on `AbortError` from VS Code's `CancellationToken` or on HTTP 4xx, (3) sends a `User-Agent` header built from the extension's `packageJSON.version` so strict gateways don't silently drop the request and so the version string can't drift again, (4) caches every successful fetch to `globalState` (`opencode.modelListCache.v1::`, TTL 1 hour) and prefers that snapshot over the bundled list when all retries fail, (5) composes the caller's `CancellationToken` with the timeout via `AbortSignal.any([...])` so a cancelled resolution tears down the in-flight fetch immediately, and (6) sends an explicit `Accept: application/json` header so SSL-inspecting corporate firewalls / VPN proxies (Zscaler, Netskope, Fortinet) don't drop the GET as an anonymous scanner — the #78 reporter sits behind a VPN + corporate firewall on Windows 11, where POST `/chat/completions` with a JSON content type was passing but the bare GET `/models` was being dropped. See `docs/issues/35-20260720-issue78-model-list-fetch-resilience.md`. + - **`[Logging]` Model registration log spam during UI refresh.** VS Code refreshes model info on roughly a 300 ms cadence during chat UI activity; each call previously produced one log line per registered model (22+ lines per call). `provideLanguageModelChatInformation` now emits a single summary line per invocation (`Models registered: count=N provider=… first=… last=…`). Output channel is dramatically cleaner during testing. - **`[Logging]` Transient model-list fetch failures no longer pop a modal warning.** OpenCode's shared gateway occasionally returns transient 400/503 responses that resolve on retry within seconds, and the previous behavior called `showWarningMessage` on every failure — including from auto-registered provider variants the user may not actively use (e.g. `OpenCode Zen (Agents)`). Failures now log to the Output channel only; the bundled `fallbackModels` snapshot keeps the picker functional. - **`[Resilience]` Model-list fetch now tolerates transient network failures (#78).** On flaky networks (and especially on VS Code 1.129 where the new agent host raises the rate of concurrent `provideLanguageModelChatInformation` calls), a single `TypeError: fetch failed` at startup — DNS wobble, TCP reset, undici socket reuse race ([`nodejs/undici#5450`](https://github.com/nodejs/undici/issues/5450)) — caused the picker to drop to the bundled list or empty out entirely ("flash then disappear"). `fetchModels()` now (1) wraps each attempt in `AbortSignal.timeout(15_000)` so a hung connect can't stall the picker for the full undici default of 5 minutes, (2) retries up to 3 times with exponential backoff (500 ms / 1 s / 2 s) on transient errors only — `ECONNRESET`, `EAI_AGAIN`, `UND_ERR_CONNECT_TIMEOUT`, HTTP 408/429/5xx, and the generic `TypeError: fetch failed` wrapper — never on `AbortError` from VS Code's `CancellationToken` or on HTTP 4xx, (3) sends a `User-Agent` header built from the extension's `packageJSON.version` so strict gateways don't silently drop the request and so the version string can't drift again, (4) caches every successful fetch to `globalState` (`opencode.modelListCache.v1::`, TTL 1 hour) and prefers that snapshot over the bundled list when all retries fail, (5) composes the caller's `CancellationToken` with the timeout via `AbortSignal.any([...])` so a cancelled resolution tears down the in-flight fetch immediately, and (6) sends an explicit `Accept: application/json` header so SSL-inspecting corporate firewalls / VPN proxies (Zscaler, Netskope, Fortinet) don't drop the GET as an anonymous scanner — the #78 reporter sits behind a VPN + corporate firewall on Windows 11, where POST `/chat/completions` with a JSON content type was passing but the bare GET `/models` was being dropped. See `docs/issues/35-20260720-issue78-model-list-fetch-resilience.md`. diff --git a/docs/devlog.md b/docs/devlog.md index 12ef99b..90af43d 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -1,5 +1,5 @@ # 🧠 OPENCODE COPILOT CHAT DEVLOG -**Branch:** `fix/issue-78-model-list-fetch-resilience` | **Updated:** 2026-07-23 Asia/Jakarta | **Current Phase:** Issue #78 — Model List Fetch Resilience ✅ Fixed, PR open pending merge +**Branch:** `fix/mimo-thinking-budget` | **Updated:** 2026-07-23 Asia/Jakarta | **Current Phase:** Issue #36 — ✅ Fixed, ready to push --- @@ -7,11 +7,67 @@ | Field | Value | |-------|-------| -| **Last Session** | 2026-07-23 | -| **Worked On** | Triaged issue #78 (reported by `@leiyu1980`, Windows 11 + VPN + corporate firewall, VS Code 1.129.0). Initial symptom looked like the closed #51 picker crash, but investigation (with web research into Node undici defaults + `nodejs/undici#5450` socket-reuse race + VS Code 1.129 agent host concurrency) confirmed it was a **transient network failure that `fetchModels()` never tolerated**. Built a 6-part resilience fix: (1) `AbortSignal.timeout(15_000)` per attempt, (2) up to 3 retries with exponential backoff (500ms/1s/2s) gated by `isTransientFetchError()` classifier, (3) `User-Agent` read from `packageJSON.version` at runtime (killed the recurring version drift), (4) `CancellationToken` composed via `AbortSignal.any([...])`, (5) 1-hour cached snapshot in `globalState` so failure falls back to last-known-good list instead of bundled, (6) `Accept: application/json` header after reporter's reply revealed their VPN/firewall passed POST `/chat/completions` but dropped bare GET `/models`. **Drive-by UX gap:** the `Refresh Models` command only existed as a sub-item inside `OpenCode Go: Manage Provider` and Zen had no Manage Provider at all — added 3 top-level commands for parity. Research stored in `/memories/repo/issue78-*.md`. Wrote `docs/issues/35-20260720-issue78-model-list-fetch-resilience.md` + updated CHANGELOG + README + bumped version `0.4.1 → 0.4.2`. Build clean: `opencode-copilot-chat-0.4.2.vsix` (1.06 MB, 115 files). | -| **Stopped At** | Ready to push `fix/issue-78-model-list-fetch-resilience` (3 commits: `8fcde64` resilience, `a04939c` commands, `ccfcb75` Accept header + version bump) and open PR. User will merge with merge commit (NEVER squash). | -| **Next Action** | → Push branch → open PR `Fixes #78` → user reviews & merges → closes #78 automatically → draft reply to `@leiyu1980` via `avoid-ai-writing` + `writing-framework-v4` skills (peer-to-peer tone, acknowledge wrong instruction in prior reply about Refresh command name). | -| **Open Issues** | (1) VS Code API gap: thread ID → session cost. (2) Qwen image quota. (3) `qwen3.6-plus-free` tool-call loop. (4) #57/#58 agent model visibility. (5) Vision proxy quota not documented in README (minor). (6) `estimateTokenCount` under-counts base64 payloads (tracked in issue doc #34). (7) `bundledModelMetadataSnapshot` could use a refresh — model list drift since 0.3.5. (8) Manual test of retry/cache path in Test C-D not run yet (network throttle + Network Link Conditioner). | +| **Last Session** | 2026-07-23 (Session 4 — stabilization) | +| **Worked On** | Final stabilization of #36 fix. After iteration 3 (suffix-repetition detection), user reported: (a) `treatReasoningAsContent` was leaking thinking to visible text, (b) `contentAfterReasoning` guard was suppressing ALL models, (c) thinking/reasoning was being "cut off" and stopping without warning. Through 4 additional iterations: (1) removed `treatReasoningAsContent` to fix leak → thinking went back to thinking panel ✅, (2) reverted `contentAfterReasoning` and `shouldSuppressTextEmit` which were false-positiving on DeepSeek/GLM/Kimi (they legitimately use `reasoning_content` then `content`), (3) re-added `treatReasoningAsContent` with correct condition: only when Go gateway + NO `reasoning_effort` in body. Key insight from web research: upstream issue #37635 is confirmed (gateway bug) and PR #37558 merged `reasoning_content` parsing — but the gateway bug itself persists. | +| **Stopped At** | All fixes verified working. Documentation updated. Ready to push. | +| **Next Action** | → Commit all changes → push `fix/mimo-thinking-budget` branch → open PR. | +| **Open Issues** | (1)-(9) same as before. (10) Log spam from agent-host provider still high (#36 debugging showed it). (11) Upstream #37635 still open, workaround can be removed when fixed server-side. | + +--- + +## 🔬 Issue #36 — MiMo 2.5 Thinking Loop — Stabilization (Session 4) + +**Commits on branch `fix/mimo-thinking-budget`:** + +| # | Commit | Description | +|---|--------|-------------| +| 1 | `52af4b3` | `budget_tokens` payload + `retry.ts` handler + initial issue doc | +| 2 | `4a7c380` | CHANGELOG + devlog + issue doc update | +| 3 | `db71214` | Suffix-repetition loop detection + `flushReasoningFallback` warning | +| 4 | *(pending)* | Final stabilization: `treatReasoningAsContent` conditional logic + revert regressions | + +**Fixes applied in stabilization:** + +1. **`treatReasoningAsContent` leak** — Thinking content was appearing as visible text in chat because the workaround was applied unconditionally for all Go gateway models. Fixed by adding condition: `treatReasoningAsContent` only activates when `reasoning_effort` is NOT in the request body. When thinking IS on (reasoning_effort present), `reasoning_content` is genuine CoT → stays in thinking panel. + +2. **Regressive suppression guards** — `contentAfterReasoning` and `shouldSuppressTextEmit` were suppressing output for ALL reasoning models (DeepSeek, GLM, Kimi). These models legitimately produce `reasoning_content` first then `content` — this is normal behavior, not degradation. Both guards fully removed. Only suffix-repetition detection remains active. + +3. **Surgical condition:** `isGoGateway && !hasReasoningEffort` — exact filter that protects MiMo thinking-OFF from gateway bug while leaving all other models untouched. + +--- + +## 🔬 Issue #36 — MiMo 2.5 Thinking Loop — Session 2026-07-23 ✅ FIXED + +**Action:** User reported MiMo 2.5 (and 2.5-Pro) thinking loops without end — same reasoning content repeated 30+ times, user blocked until 10-min total timeout. Initial investigation coded `budget_tokens` cap (low=8K, medium=16K, high=32K) in `buildThinkingPayload()`. User rightly questioned: "why does it loop even with a cap?" + +**Branch:** `fix/mimo-thinking-budget` (created from `fix/issue-78-model-list-fetch-resilience`) + +**Compile:** `npm run compile` exit 0 (verified every change) + +**Root cause — two distinct problems found:** + +| # | Problem | Layer | Fix | +|---|---------|-------|-----| +| 1 | **Go gateway bug #37635** — opencode-go places ALL streaming text in `reasoning_content` instead of `content`. All Go models affected (mimo, deepseek, kimi, glm, etc.). Confirmed via direct API test by issue reporter. | Gateway (upstream) | `treatReasoningAsContent` workaround: detect `/zen/go/` URL path, emit reasoning as visible text | +| 2 | **MiMo model not converging** — model's chain-of-thought enters self-referential loop generating same text repeatedly. | Model level | `budget_tokens` caps damage; upstream fix needed from model provider | + +**Web research findings:** + +- `anomalyco/opencode#37635` (5 days old, assigned MrMushrooooom): "opencode-go gateway returns reasoning_content instead of content in streaming responses" — confirmed ALL opencode-go models. Non-streaming OK. Zen gateway OK. +- `anomalyco/opencode#35209` (3 weeks, assigned StarpTech): "models go into extended thinking on simple prompts" — related: thinking options not gated by model capabilities. +- `anomalyco/opencode#36354` (2 weeks, assigned jlongster): "MiMo / DeepSeek tool-call Internal server error" — reasoning_content handling broken for tool calls. + +**Changes made (1 commit `52af4b3`):** + +1. `src/thinking.ts` — `buildThinkingPayload()` MiMo branch: add `budget_tokens` per effort level with retry.ts handler map. +2. `src/retry.ts` — Add HTTP 400 handler for `budget_tokens` rejection (graceful degradation). +3. `src/streaming.ts` — `OpenAiResponseExtractor`: add `treatReasoningAsContent` parameter + constructor + logic. `streamChatCompletions`: detect Go gateway via URL path `/zen/go/`. +4. `docs/issues/36-20260723-mimo-thinking-infinite-loop.md` — Full issue documentation. +5. `CHANGELOG.md` — Added to [Unreleased] section. + +**Workaround trade-off:** Go models lose legitimate thinking surfacing (CoT appears as visible text), but they were already broken — gateway mixes CoT + answer in `reasoning_content`. Zen models unaffected. Fix reversible when upstream #37635 is resolved. + +**Manual verification:** Not run (requires Go API key + MiMo model). Workaround is deterministic — URL check, no runtime deps on model behavior. --- diff --git a/docs/issues/36-20260723-mimo-thinking-infinite-loop.md b/docs/issues/36-20260723-mimo-thinking-infinite-loop.md new file mode 100644 index 0000000..a1e4d7a --- /dev/null +++ b/docs/issues/36-20260723-mimo-thinking-infinite-loop.md @@ -0,0 +1,145 @@ +**Status:** ✅ Solved (with upstream workaround) + +# MiMo 2.5 — Thinking Loops + Go Gateway Reasoning Leak (#36) + +**Topic:** thinking / mimo / streaming / gateway / workaround +**Reported:** 2026-07-23 +**Tags:** #thinking #mimo #streaming #gateway #workaround #bug + +--- + +## Problem + +Two distinct but related issues affect MiMo 2.5 on the opencode-go gateway: + +### Problem A — Thinking loop (model level) + +MiMo 2.5's reasoning can enter an infinite loop — repeating the same chain-of-thought fragment indefinitely without converging. The stream is actively generating tokens, so `DEFAULT_STREAM_IDLE_TIMEOUT_MS` (2 min) does not fire. User blocked for up to 10 minutes. + +### Problem B — All response text in `reasoning_content` (gateway bug) + +The opencode-go gateway wraps ALL streaming response text inside `reasoning_content` instead of `content` (issue [#37635](https://github.com/anomalyco/opencode/issues/37635)). This means: + +- When MiMo thinking is OFF: the model's actual response (answer text) appears in `reasoning_content` → gets emitted as a thinking part → user sees nothing or truncated response +- When MiMo thinking is ON: CoT goes to thinking panel (correct), but answer also goes to `reasoning_content` → leaked to thinking panel or visible text depending on chunk order + +--- + +## Root Cause + +### Problem A — No token budget + +MiMo uses `@ai-sdk/openai-compatible`. OpenCode's transform only sends `reasoningEffort` — no budget cap: + +```typescript +// OpenCode transform.ts +return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map(effort => [effort, { reasoningEffort: effort }])) +// reasoningBudget() for @ai-sdk/openai-compatible → returns undefined +``` + +### Problem B — Gateway bug (#37635) + +Confirmed via direct API test on the Go gateway: + +``` +POST https://opencode.ai/zen/go/v1/chat/completions +→ All streaming chunks use `reasoning_content` for ALL output +→ Final chunk has `content: "answer"` (only answer, not CoT) +→ Non-streaming endpoint returns `content` correctly (only streaming affected) +``` + +**Affected:** ALL opencode-go models (deepseek, kimi, glm, mimo, minimax, qwen, grok). +**Not affected:** Zen gateway (`/zen/v1/`). + +Related upstream issues: + +| Issue | Status | Relevance | +|-------|--------|-----------| +| [#37635](https://github.com/anomalyco/opencode/issues/37635) | 🟡 Open (MrMushrooooom) | Gateway bug — server-side fix needed | +| [#35209](https://github.com/anomalyco/opencode/issues/35209) | 🟡 Open (StarpTech) | Extended thinking on simple prompts | +| [#36354](https://github.com/anomalyco/opencode/issues/36354) | 🟡 Open (jlongster) | MiMo / DeepSeek tool-call errors | + +--- + +## Fix (v0.4.2) + +### Fix 1 — `budget_tokens` in thinking payload (`src/thinking.ts`) + +Added a `budget_tokens` field alongside `reasoning_effort` to cap reasoning token generation: + +| Effort | `reasoning_effort` | `budget_tokens` | +|--------|-------------------|-----------------| +| low | `"low"` | 8 192 | +| medium | `"medium"` | 16 384 | +| high | `"high"` | 32 768 | + +If the gateway rejects `budget_tokens` (HTTP 400), `retry.ts` handler removes it and retries with only `reasoning_effort`. + +### Fix 2 — Suffix-repetition loop detection (`src/streaming.ts`) + +Added `shouldSuppressThinkingEmit()` in `OpenAiResponseExtractor.handleReasoning()`. When the same 40-char suffix repeats across 6+ consecutive reasoning chunks, the model is stuck in a word-level loop. Further thinking parts are suppressed and a visible warning `[Reasoning loop detected — thinking output suppressed]` is emitted. + +### Fix 3 — Go gateway `reasoning_content` workaround (`src/streaming.ts`) + +Added `treatReasoningAsContent` parameter to `OpenAiResponseExtractor`. In `extractStreamParts`, when this flag is on AND `delta.content` is empty AND `reasoning_content` exists, the reasoning is emitted as visible `LanguageModelTextPart` instead of a thinking part. + +**Critical condition:** The workaround only activates when ALL three conditions are true: + +1. Request URL includes `/zen/go/` (Go gateway) +2. `reasoning_effort` is NOT in the request body (MiMo thinking is OFF) +3. `delta.content` is empty + +When `reasoning_effort` IS present (MiMo thinking ON), `reasoning_content` is genuine CoT and should stay in the thinking panel — the workaround is NOT applied. + +--- + +## Why surgical conditions matter + +Without the condition check, the workaround would break all models: + +| Model | Go gateway? | reasoning_effort in body? | Workaround active? | Result | +|-------|------------|--------------------------|--------------------|----| +| MiMo thinking OFF | ✅ | ❌ | ✅ | `reasoning_content` → visible text (fix) | +| MiMo thinking ON | ✅ | ✅ | ❌ | `reasoning_content` → thinking panel (correct) | +| DeepSeek (any) | ✅ | ✅ | ❌ | `reasoning_content` → thinking panel (correct) | +| GLM, Kimi, Qwen | ✅ | varies | varies | Same logic applies | +| Any model on Zen | ❌ | n/a | ❌ | Untouched | + +--- + +## Debug logging + +The extension logs diagnostic info to the "OpenCode" output channel: + +``` +[go-gw] model=mimo-v2.5 hasReasoningEffort=false treatReasoningAsContent=true +[go-gw] model=deepseek-v4-pro hasReasoningEffort=true treatReasoningAsContent=false +[mimo] reasoning loop: suffix repeated 6x. Suppressing thinking parts. +``` + +--- + +## Fallback behavior + +If `budget_tokens` is not supported: + +1. Gateway returns `HTTP 400` → `retry.ts` removes `budget_tokens` +2. Retries with `{ reasoning_effort: "low"|"medium"|"high" }` only +3. Loop detection (suffix repetition) still applies as backup + +--- + +## Workaround lifecycle + +This workaround can be removed once upstream [#37635](https://github.com/anomalyco/opencode/issues/37635) is fixed server-side. The condition check (`isGoGateway && !hasReasoningEffort`) makes it zero-risk for other providers. + +--- + +## Files changed + +| File | Change | +|------|--------| +| `src/thinking.ts` | `buildThinkingPayload()` — `budget_tokens` per MiMo effort level | +| `src/retry.ts` | `analyzeHttp400ForRetry()` — handler for `budget_tokens` rejection | +| `src/streaming.ts` | `OpenAiResponseExtractor` — `treatReasoningAsContent` constructor param, `shouldSuppressThinkingEmit()`, suffix-repetition detection | +| `src/streaming.ts` | `streamChatCompletions()` — Go gateway detection via URL + body check | diff --git a/src/retry.ts b/src/retry.ts index e971215..7caaff0 100644 --- a/src/retry.ts +++ b/src/retry.ts @@ -134,6 +134,16 @@ const RECOVERABLE_ERROR_PATTERNS: Array<{ }, describe: () => "removed thinking_budget (not accepted by this model)", }, + // budget_tokens — used by Mimo thinking payload to cap reasoning tokens + { + pattern: /extra inputs are not permitted.*budget_tokens/i, + patch: (body) => { + const next = { ...body }; + delete next.budget_tokens; + return next; + }, + describe: () => "removed budget_tokens (not accepted by this model)", + }, // --- Generic extra inputs --- // "Extra inputs are not permitted, field: ''" diff --git a/src/streaming.ts b/src/streaming.ts index bc461d6..f9fc510 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -82,12 +82,30 @@ export async function streamChatCompletions( options: StreamRequestOptions, ): Promise { const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); + // Workaround for opencode-go gateway bug (#37635): the Go gateway wraps ALL + // streaming responses in `reasoning_content`. Only apply when: + // 1. Request goes to the Go gateway URL (/zen/go/) + // 2. `reasoning_effort` is NOT in the body (model thinking is OFF) + // When thinking IS on (reasoning_effort present), reasoning_content is genuine + // CoT and should remain in the thinking panel. + const isGoGateway = options.url.includes("/zen/go/"); + const body = options.body as Record | undefined; + const hasReasoningEffort = isGoGateway && + (typeof body?.reasoning_effort === "string" || typeof body?.budget_tokens === "number"); + const treatReasoningAsContent = isGoGateway && !hasReasoningEffort; + if (isGoGateway) { + options.output?.appendLine( + `[go-gw] model=${options.modelId} hasReasoningEffort=${hasReasoningEffort} treatReasoningAsContent=${treatReasoningAsContent}`, + ); + } const extractor = new OpenAiResponseExtractor( options.onReasoningContent, createReasoningDebugger(options.output, options.debugReasoning), thinkFilter, options.progress, options.requestHeaders["x-opencode-request"], + options.output, + treatReasoningAsContent, ); await streamOpenCodeResponse({ @@ -103,6 +121,11 @@ export async function streamChatCompletions( options.output?.appendLine( `[stream-summary model=${options.modelId}] textChars=${extractor.emittedText} toolCalls=${extractor.emittedTools} reasoningChars=${extractor.reasoningChars}`, ); + if (extractor.reasoningLoopSuppressed) { + options.output?.appendLine( + `[warn] model=${options.modelId} output suppressed after ~${extractor.emittedText} visible chars (probable model degradation at large context). Try a shorter conversation or use a different model.`, + ); + } if (extractor.emittedText === 0 && extractor.emittedTools === 0) { options.output?.appendLine( `[warn] empty response from model=${options.modelId} (no text, no tool calls, no reasoning). Try a different free model or enable opencodego.debugReasoning to inspect raw SSE.`, @@ -850,6 +873,25 @@ class OpenAiResponseExtractor { */ private totalReasoningChars = 0; + /** + * Reasoning loop suppression state. + * + * When the model generates excessive reasoning without progress (visible text + * or tool calls), thinking parts are suppressed and a warning is emitted. + */ + private _reasoningLoopSuppressed = false; + private reasoningLoopWarningEmitted = false; + private reasoningLoopLogGuard = false; + /** + * Suffix-based chunk-level repetition guard. When N consecutive reasoning + * fragments share the same 40-char suffix, the model is in a word-level + * loop and further output is suppressed. + */ + private readonly reasoningFragmentSuffixes: string[] = []; + private static readonly REASONING_LOOP_SUFFIX_MATCHES = 6; + + + constructor( private readonly onReasoningContent?: ( toolCallIds: string[], @@ -865,6 +907,25 @@ class OpenAiResponseExtractor { */ private readonly progress?: vscode.Progress, private readonly localRequestId?: string, + /** + * Optional output channel for debug logging. + */ + private readonly output?: vscode.OutputChannel, + /** + * Workaround for opencode-go gateway bug (#37635). + * + * The Go gateway wraps ALL model streaming responses in `reasoning_content` + * instead of `content`. When this flag is `true` AND a delta has + * `reasoning_content` but no `content`, the reasoning is emitted as + * visible `LanguageModelTextPart` instead of as a thinking part. + * + * CONTRACT: + * - Only set for Go-gateway requests where `reasoning_effort` is NOT in the + * payload (i.e. MiMo thinking is OFF). When thinking IS on, the model + * genuinely uses reasoning_content for CoT → goes to thinking panel. + * - Zen gateway and all non-Go models are never affected. + */ + private readonly treatReasoningAsContent: boolean = false, ) {} get emittedText(): number { @@ -879,10 +940,19 @@ class OpenAiResponseExtractor { return this.totalReasoningChars; } + /** Whether the reasoning loop suppression was triggered. */ + get reasoningLoopSuppressed(): boolean { + return this._reasoningLoopSuppressed; + } + /** * Accumulate reasoning for tool-call replication, and — when the thinking * part API is available — stream it live to the Copilot Chat UI. * + * Also detects reasoning loops: if total reasoning chars exceeds a threshold + * without any visible text or tool calls being emitted, the model is likely + * stuck and further thinking parts are suppressed. + * * Returns the reasoning string that was handled (for logging/debug). */ private handleReasoning(reasoning: string): string { @@ -891,6 +961,13 @@ class OpenAiResponseExtractor { } this.reasoningContent += reasoning; this.totalReasoningChars += reasoning.length; + + // Reasoning loop guard: suppress if suffix repetition detected + if (this.shouldSuppressThinkingEmit(reasoning)) { + // Accumulate but don't emit — loop detected + return reasoning; + } + // Stream reasoning to the UI per-chunk as a thinking part, so that // chat.agent.thinkingStyle (collapsed / collapsedPreview / fixedScrolling) // can apply. Falls back to legacy accumulate-only when the API is absent. @@ -900,6 +977,51 @@ class OpenAiResponseExtractor { return reasoning; } + /** + * Check whether reasoning should be suppressed due to a detected loop. + * + * Only guard: **suffix repetition** — same 40-char suffix on 6+ consecutive + * chunks. This catches actual word-level repetition loops without false + * positives on fresh conversations where the model legitimately reasons + * for thousands of chars before producing output. + * + * A char-budget guard was previously used but removed because it triggered + * false positives on normal fresh conversations (model can legitimately + * produce 3000+ thinking chars before visible output or tool calls). + */ + private shouldSuppressThinkingEmit(chunk: string): boolean { + if (this._reasoningLoopSuppressed) { + return true; + } + + // Guard: suffix repetition + if (chunk.length >= 10) { + const suffix = chunk.slice(-40); + const lastSuffix = this.reasoningFragmentSuffixes.at(-1); + if (lastSuffix !== undefined && suffix === lastSuffix) { + this.reasoningFragmentSuffixes.push(suffix); + if (this.reasoningFragmentSuffixes.length >= 6) { + this._reasoningLoopSuppressed = true; + this.output?.appendLine( + `[mimo] reasoning loop: suffix repeated 6x. Suppressing thinking parts.`, + ); + } + } else { + this.reasoningFragmentSuffixes.length = 0; + this.reasoningFragmentSuffixes.push(suffix); + } + } + + if (this._reasoningLoopSuppressed && !this.reasoningLoopLogGuard) { + this.reasoningLoopLogGuard = true; + this.output?.appendLine( + `[mimo] reasoning loop suppression ACTIVE. Thinking parts will be dropped.`, + ); + } + + return this._reasoningLoopSuppressed; + } + extractStreamParts(data: unknown): vscode.LanguageModelResponsePart[] { if (!isRecord(data) || !Array.isArray(data.choices)) { return []; @@ -924,7 +1046,18 @@ class OpenAiResponseExtractor { } const reasoning = extractReasoningFromDelta(delta); if (reasoning) { - this.handleReasoning(reasoning); + // Workaround for opencode-go gateway bug (#37635): + // When treatReasoningAsContent is true AND delta.content is empty, + // the model's response was placed in reasoning_content by the gateway. + // Emit as visible text. Suffix-repetition loop guard still applies. + if (this.treatReasoningAsContent && !visible && text.length === 0) { + if (!this.shouldSuppressThinkingEmit(reasoning)) { + this.emittedTextLength += reasoning.length; + parts.push(new vscode.LanguageModelTextPart(reasoning)); + } + } else { + this.handleReasoning(reasoning); + } } this.collectOpenAiToolCalls(delta.tool_calls); } @@ -971,6 +1104,18 @@ class OpenAiResponseExtractor { progress: vscode.Progress, localRequestId?: string, ): void { + // Emit a visible warning if a reasoning loop was detected and suppressed + if (this._reasoningLoopSuppressed && !this.reasoningLoopWarningEmitted) { + this.reasoningLoopWarningEmitted = true; + const warning = "[Reasoning loop detected — thinking output suppressed]"; + reportProgressPart( + localRequestId, + progress, + new vscode.LanguageModelTextPart(warning), + ); + this.emittedTextLength += warning.length; + } + // Flush any remaining text in the think filter if (this.thinkFilter) { const { visible, thinking } = this.thinkFilter.finish(); diff --git a/src/thinking.ts b/src/thinking.ts index ec7f1b2..2d5b5c1 100644 --- a/src/thinking.ts +++ b/src/thinking.ts @@ -443,10 +443,28 @@ export function buildThinkingPayload(modelId: string, thinking: ThinkingSettings if (/^mimo-/i.test(modelId)) { // Mimo models use OpenAI-compatible chat-completions with reasoning_content. // Supported efforts: low, medium, high (per OpenCode upstream defaults). + // + // budget_tokens caps the reasoning token count to prevent infinite thinking + // loops observed in mimo-v2.5 / mimo-v2.5-pro (issue #36, 2026-07-23). + // Effort → token budget mapping (conservative caps; tuned for practical tasks): + // low → 8 192 (~2× a typical short CoT) + // medium → 16 384 (~4× a typical medium CoT) + // high → 32 768 (~8× a deeper reasoning chain) + // If the OpenCode gateway rejects budget_tokens (HTTP 400 "extra inputs"), + // retry.ts drops the field and retries with reasoning_effort alone. if (thinking.mimo === "off") { return {}; } - return { reasoning_effort: thinking.mimo }; + const mimoBudgetMap: Record = { + low: 8192, + medium: 16384, + high: 32768, + }; + const mimoBudget = mimoBudgetMap[thinking.mimo]; + return { + reasoning_effort: thinking.mimo, + ...(mimoBudget !== undefined ? { budget_tokens: mimoBudget } : {}), + }; } if (/^minimax-/i.test(modelId)) {