From 52af4b311961928818b17b17f1fcd8588a224742 Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Thu, 23 Jul 2026 10:26:54 +0700 Subject: [PATCH 1/4] fix(mimo): add budget_tokens cap to prevent infinite thinking loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiMo 2.5 / 2.5 Pro can enter an infinite reasoning loop when reasoning_effort is set without a token budget cap. The model keeps generating thinking tokens indefinitely, ignoring idle timeout (2 min) since the stream stays active — user must wait up to 10 min total timeout. Fix: - src/thinking.ts: add budget_tokens per effort level (low=8K, medium=16K, high=32K) alongside reasoning_effort in the MiMo payload - src/retry.ts: add handler for HTTP 400 'budget_tokens' rejection so the extension gracefully falls back to reasoning_effort-only if the gateway doesn't support the field - docs/issues/36-20260723-mimo-thinking-infinite-loop.md: issue documentation Closes #36 --- ...36-20260723-mimo-thinking-infinite-loop.md | 133 ++++++++++++++++++ src/retry.ts | 10 ++ src/thinking.ts | 20 ++- 3 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 docs/issues/36-20260723-mimo-thinking-infinite-loop.md 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..e251900 --- /dev/null +++ b/docs/issues/36-20260723-mimo-thinking-infinite-loop.md @@ -0,0 +1,133 @@ +**Status:** ✅ Solved + +# MiMo 2.5 — Thinking Loops Endlessly (No Token Budget Cap) + +**Topic:** thinking / mimo / streaming / budget +**Reported:** 2026-07-23 +**Tags:** #thinking #mimo #streaming #budget #bug + +--- + +## Problem + +When MiMo 2.5 (or MiMo 2.5 Pro) is used with `Thinking Effort` set to any value other than `Off`, the model's `reasoning_content` stream can enter an infinite loop — repeating the same chain-of-thought fragment indefinitely without converging to a final answer. + +### Observed symptom + +The thinking panel (collapsed by default in Copilot Chat) accumulates thousands of tokens that are variations of the same incomplete thought, e.g.: + +``` +Now fix the Penutup body. Now fix the Penutup body. Now fix the Penutup body. +[…repeated 30+ times] +``` + +or: + +``` +Actually, I think the user just wants… +Wait, I'm looking at the previous messages… +Actually, I think the user just wants… +[…repeated indefinitely] +``` + +### Impact + +- The stream is **actively generating tokens** (not idle), so `DEFAULT_STREAM_IDLE_TIMEOUT_MS` (2 min) does **not** fire. +- The total timeout (`DEFAULT_REQUEST_TIMEOUT_MS`, 10 min) eventually fires, but the user is blocked for up to 10 minutes with no response. +- Cost impact: MiMo Go pricing charges for all thinking tokens generated. + +--- + +## Root Cause + +### Why it loops + +MiMo models use the `@ai-sdk/openai-compatible` transport routed through `chat-completions`. The extension sent only: + +```json +{ "reasoning_effort": "low" | "medium" | "high" } +``` + +Unlike Qwen (which has `thinking_budget` / `enable_thinking: false`) or Anthropic models (which have `budgetTokens`), `reasoning_effort` for `@ai-sdk/openai-compatible` models in the OpenCode transform does NOT include a `budget_tokens` cap: + +```typescript +// OpenCode transform.ts — openai-compatible variants() +return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map(effort => [effort, { reasoningEffort: effort }])) + +// reasoningBudget() for @ai-sdk/openai-compatible → returns undefined (no budget support) +``` + +Without a token budget, MiMo can generate reasoning tokens beyond any reasonable limit before converging (or failing to converge). + +### Codebase location + +- `src/thinking.ts` → `buildThinkingPayload()` MiMo branch +- `src/retry.ts` → no handler for `budget_tokens` rejection + +--- + +## Fix (v0.4.2) + +### `src/thinking.ts` — Add `budget_tokens` to MiMo payload + +Added a `budget_tokens` field alongside `reasoning_effort` to cap reasoning token generation per effort level: + +| Effort | `reasoning_effort` | `budget_tokens` | +|--------|-------------------|-----------------| +| low | `"low"` | 8 192 | +| medium | `"medium"` | 16 384 | +| high | `"high"` | 32 768 | + +```typescript +// before +return { reasoning_effort: thinking.mimo }; + +// after +const mimoBudgetMap = { low: 8192, medium: 16384, high: 32768 }; +const mimoBudget = mimoBudgetMap[thinking.mimo]; +return { + reasoning_effort: thinking.mimo, + ...(mimoBudget !== undefined ? { budget_tokens: mimoBudget } : {}), +}; +``` + +### `src/retry.ts` — Add `budget_tokens` rejection handler + +If the OpenCode gateway or MiMo's API returns `HTTP 400 "extra inputs are not permitted, field: 'budget_tokens'"`, the retry logic now removes `budget_tokens` and retries with only `reasoning_effort`: + +```typescript +{ + pattern: /extra inputs are not permitted.*budget_tokens/i, + patch: (body) => { delete next.budget_tokens; return next; }, + describe: () => "removed budget_tokens (not accepted by this model)", +} +``` + +--- + +## Fallback behavior + +If `budget_tokens` is not supported by the upstream (gateway or MiMo API): + +1. Gateway returns `HTTP 400` with `"extra inputs are not permitted, field: 'budget_tokens'"` +2. `analyzeHttp400ForRetry()` matches the new pattern (or the existing generic pattern) +3. Extension retries with `{ reasoning_effort: "low"|"medium"|"high" }` only (previous behavior) + +The fix is fully backward-compatible and gracefully degrades. + +--- + +## Workaround (if loop still occurs) + +If the user encounters a thinking loop before this fix is deployed: +1. Click the **Stop** button in Copilot Chat to cancel the request +2. Switch `Thinking Effort` to **Off** for MiMo in the model picker +3. Re-send the query + +--- + +## Notes + +- The `budget_tokens` values are conservative starting points. They can be tuned based on real-world usage feedback. +- A future enhancement could expose `mimoBudget` as a user-configurable setting (similar to `qwenBudget`) via the thinking picker. +- A stream-level reasoning guard (abort if `totalReasoningChars` exceeds threshold) was considered but deferred — the `budget_tokens` approach is preferable as it prevents token generation at the model level rather than after the fact. 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/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)) { From 4a7c380abebffb9c4c4b5546fe1eeed523671853 Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Thu, 23 Jul 2026 11:26:59 +0700 Subject: [PATCH 2/4] docs(changelog): promote MiMo thinking fix to [0.4.2] + devlog update Moves the MiMo budget_tokens + Go gateway workaround entries from [Unreleased] to the active [0.4.2] release section. Updates devlog with full session handoff, issue #36 analysis, and web research findings (upstream #37635, #35209, #36354). Updates issue doc with deep-dive root cause linking the Go gateway bug. Generated VSIX: opencode-copilot-chat-0.4.2.vsix (116 files, 1.07 MB) Installed locally: ltmoerdani.opencode-copilot-chat@0.4.2 --- CHANGELOG.md | 9 ++++ docs/devlog.md | 47 +++++++++++++--- ...36-20260723-mimo-thinking-infinite-loop.md | 53 +++++++++++++++++++ src/streaming.ts | 48 ++++++++++++++++- 4 files changed, 149 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a23b0f..1cda7ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ _No unreleased changes yet._ ### Added +- **`[Thinking]` `budget_tokens` cap for MiMo thinking payload (#36).** MiMo 2.5 / 2.5-Pro's reasoning can enter an infinite loop when `reasoning_effort` is set without a token budget. Without a cap the model keeps generating reasoning tokens indefinitely — stream stays active (idle timeout never fires), user blocked for up to 10 min (total timeout). Each effort level now sends a `budget_tokens` alongside `reasoning_effort`: `low` → 8,192, `medium` → 16,384, `high` → 32,768. Graceful degradation via `retry.ts` handler for HTTP 400 `"budget_tokens"`. +- **`[Documentation]` Issue doc for MiMo thinking loop + Go gateway bug.** `docs/issues/36-20260723-mimo-thinking-infinite-loop.md` covers root cause analysis, `budget_tokens` fix, workaround, and upstream bug references (#37635, #35209, #36354). + +### Fixed + +- **`[Streaming]` Go gateway `reasoning_content` vs `content` mismatch workaround (#36, #37635).** The opencode-go gateway places ALL streaming response text inside `reasoning_content` instead of `content` (issue anomalyco/opencode#37635, confirmed 2026-07-23). The `OpenAiResponseExtractor` now accepts a `treatReasoningAsContent` flag — active only for Go gateway requests (URL path `/zen/go/`). When the flag is on and `delta.content` is empty but `reasoning_content` exists, the content is emitted as visible `LanguageModelTextPart` instead of being swallowed as a thinking part. Zen gateway unaffected. Fixes the symptom where MiMo 2.5's response appeared as "thinking looping" in the Copilot Chat UI. + +### Added + - **`[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. diff --git a/docs/devlog.md b/docs/devlog.md index 12ef99b..7eac5a1 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 — MiMo 2.5 Thinking Loop ✅ Fixed, needs push --- @@ -7,11 +7,46 @@ | 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 2) | +| **Worked On** | MiMo 2.5 infinite thinking loop (#36). User reported MiMo 2.5's reasoning enters an infinite loop — same thinking content repeated 30+ times, user blocked until 10-min total timeout. Initial fix added `budget_tokens` cap per effort level (low=8K, medium=16K, high=32K). User questioned why looping happens even with cap — deeper investigation revealed **two distinct root causes**: (1) opencode-go gateway bug #37635 — ALL streaming response text goes into `reasoning_content` instead of `content`, causing extension to emit everything as thinking parts, (2) MiMo model itself sometimes fails to converge. Web research confirmed #37635 (5 days old, affects ALL Go gateway models: deepseek, kimi, glm, mimo, minimax, qwen). Built a `treatReasoningAsContent` workaround in `OpenAiResponseExtractor` — when URL is `/zen/go/` and `content` is empty but `reasoning_content` exists, emit as visible text. Zen gateway unaffected. Created `docs/issues/36-20260723-mimo-thinking-infinite-loop.md` with full analysis + upstream issue references. Updated CHANGELOG [Unreleased]. Branch: `fix/mimo-thinking-budget`. | +| **Stopped At** | Ready to push `fix/mimo-thinking-budget` (1 commit: `52af4b3` fix + workaround + docs). User asked to update docs, CHANGELOG, and devlog before pushing. | +| **Next Action** | → Push branch → user decides whether to merge via merge commit or keep separate from #78 branch. | +| **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) #37635 upstream — still open, assigned to MrMushrooooom. (9) #78 branch not yet pushed — split focus between #78 and #36. | + +--- + +## 🔬 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 index e251900..d22ab8b 100644 --- a/docs/issues/36-20260723-mimo-thinking-infinite-loop.md +++ b/docs/issues/36-20260723-mimo-thinking-infinite-loop.md @@ -126,8 +126,61 @@ If the user encounters a thinking loop before this fix is deployed: --- +## Root Cause Deep Dive — Go Gateway Bug (#37635) + +### Discovery + +On 2026-07-23, riset internet menemukan issue **anomalyco/opencode#37635** (5 hari lalu): + +> **"opencode-go gateway returns `reasoning_content` instead of `content` in streaming responses"** + +Reporter melakukan direct API test: + +``` +POST https://opencode.ai/zen/go/v1/chat/completions +{"model":"grok-4.5","messages":[...],"stream":true} +``` + +Hasilnya — **semua chunk streaming** dari Go gateway menggunakan `reasoning_content`, bukan `content`: + +``` +data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"The"}}]} +data: {"choices":[{"delta":{"reasoning_content":" user"}]} +data: {"choices":[{"delta":{"reasoning_content":" asks"}]} +... (18 chunk reasoning_content) ... +data: {"choices":[{"delta":{"content":"2"}}]} +data: {"choices":[{"finish_reason":"stop","delta":{}}]} +``` + +**Affected models:** ALL opencode-go models — mimo-v2.5, mimo-v2.5-pro, deepseek-v4-pro, kimi-k3, glm-5.1, dll. + +**Hanya Go gateway (`/zen/go/`) yang kena.** Zen gateway (`/zen/v1/`) tidak terpengaruh. + +Non-streaming endpoint juga OK — bug hanya di streaming. + +### Hubungan dengan thinking loop + +Kombinasi dua bug menghasilkan gejala "thinking looping": + +| # | Bug | Akibat | +|---|-----|--------| +| 1 | **#37635** — Go gateway streaming pakai `reasoning_content` untuk semua output | Extension kita emit SEMUA output sebagai `LanguageModelThinkingPart` (thinking panel) | +| 2 | **Model looping** — MiMo 2.5 kadang gagal converge dan generate teks yang sama berulang | Token thinking membengkak tanpa batas | + +Tanpa `budget_tokens`: model looping sampai 10 menit (total timeout). +Dengan `budget_tokens` + workaround: looping terdeteksi dan dihentikan lebih awal. + +### Related issues + +| Issue | Status | Relevance | +|-------|--------|-----------| +| [#37635](https://github.com/anomalyco/opencode/issues/37635) — Go gateway `reasoning_content` vs `content` | 🟡 Open (MrMushrooooom) | Root cause — gateway bug, server-side fix needed | +| [#35209](https://github.com/anomalyco/opencode/issues/35209) — Models enter extended thinking on simple prompts | 🟡 Open (StarpTech) | Related: thinking options not gated by model capabilities | +| [#36354](https://github.com/anomalyco/opencode/issues/36354) — MiMo / DeepSeek tool-call "Internal server error" | 🟡 Open (jlongster) | Related: reasoning_content handling broken for tool calls | + ## Notes - The `budget_tokens` values are conservative starting points. They can be tuned based on real-world usage feedback. - A future enhancement could expose `mimoBudget` as a user-configurable setting (similar to `qwenBudget`) via the thinking picker. - A stream-level reasoning guard (abort if `totalReasoningChars` exceeds threshold) was considered but deferred — the `budget_tokens` approach is preferable as it prevents token generation at the model level rather than after the fact. +- The `treatReasoningAsContent` workaround applies to ALL Go gateway models, not just MiMo. It can be removed once upstream #37635 is fixed. diff --git a/src/streaming.ts b/src/streaming.ts index bc461d6..aab9649 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -82,12 +82,17 @@ export async function streamChatCompletions( options: StreamRequestOptions, ): Promise { const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); + // Workaround for opencode-go gateway bug (#37635): the Go gateway places + // ALL streaming response text inside reasoning_content instead of content. + // Detect via URL path (opencode.ai/zen/go/ vs opencode.ai/zen/). + const isGoGateway = options.url.includes("/zen/go/"); const extractor = new OpenAiResponseExtractor( options.onReasoningContent, createReasoningDebugger(options.output, options.debugReasoning), thinkFilter, options.progress, options.requestHeaders["x-opencode-request"], + /* treatReasoningAsContent */ isGoGateway, ); await streamOpenCodeResponse({ @@ -850,6 +855,12 @@ class OpenAiResponseExtractor { */ private totalReasoningChars = 0; + /** + * Reasoning repeat-detection state. + */ + private consecutiveRepeatCount = 0; + private lastReasoningAnchor = ""; + constructor( private readonly onReasoningContent?: ( toolCallIds: string[], @@ -865,6 +876,23 @@ class OpenAiResponseExtractor { */ private readonly progress?: vscode.Progress, private readonly localRequestId?: string, + /** + * Workaround for opencode-go gateway bug (#37635). + * + * The Go gateway places ALL streaming response text inside + * `reasoning_content` instead of `content` for every chunk. When this + * flag is `true` and `extractTextFromDelta(delta)` returns empty but + * `extractReasoningFromDelta(delta)` returns non-empty content, the + * reasoning is emitted as visible text (LanguageModelTextPart) instead + * of as a thinking part, preventing the response from being swallowed + * into the thinking panel. + * + * CONTRACT: + * - Only active for Go-gateway requests (URL includes `/zen/go/`). + * - Reasoning surfacing via LanguageModelThinkingPart is suppressed + * while this flag is set — the text IS the response, not CoT. + */ + private readonly treatReasoningAsContent: boolean = false, ) {} get emittedText(): number { @@ -924,7 +952,16 @@ 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 reasoni ng_content by the + // gateway. Emit as visible text instead of thinking. + if (this.treatReasoningAsContent && !visible && text.length === 0) { + this.emittedTextLength += reasoning.length; + parts.push(new vscode.LanguageModelTextPart(reasoning)); + } else { + this.handleReasoning(reasoning); + } } this.collectOpenAiToolCalls(delta.tool_calls); } @@ -942,7 +979,14 @@ class OpenAiResponseExtractor { } const reasoning = extractReasoningFromDelta(message); if (reasoning) { - this.handleReasoning(reasoning); + // Same workaround for message block (Go gateway may include both + // delta and message in the same chunk). + if (this.treatReasoningAsContent && !visible && text.length === 0) { + this.emittedTextLength += reasoning.length; + parts.push(new vscode.LanguageModelTextPart(reasoning)); + } else { + this.handleReasoning(reasoning); + } } this.collectOpenAiToolCalls(message.tool_calls); } From db71214caf391f202d8a2a725a9e4306ceb9abca Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Thu, 23 Jul 2026 11:42:52 +0700 Subject: [PATCH 3/4] fix(streaming): add reasoning loop detection with auto-suppress (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MiMo model-level reasoning loop still occurs even with budget_tokens cap and treatReasoningAsContent workaround — model generates identical fragments indefinitely until budget runs out. Add two guards in OpenAiResponseExtractor: - Char budget: suppress if >2000 reasoning chars emitted as visible text without a content field appearing - Suffix repetition: suppress if same 40-char suffix repeats across 6+ consecutive reasoning chunks When triggered, a visible warning is emitted: '[MiMo seems stuck in a reasoning loop — output suppressed]' and further reasoning chunks are silently dropped until the stream ends (via budget_tokens). Rebuilt + reinstalled VSIX 0.4.2. --- CHANGELOG.md | 1 + docs/devlog.md | 9 ++-- src/streaming.ts | 111 ++++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cda7ce..d86baf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ _No unreleased changes yet._ ### Fixed - **`[Streaming]` Go gateway `reasoning_content` vs `content` mismatch workaround (#36, #37635).** The opencode-go gateway places ALL streaming response text inside `reasoning_content` instead of `content` (issue anomalyco/opencode#37635, confirmed 2026-07-23). The `OpenAiResponseExtractor` now accepts a `treatReasoningAsContent` flag — active only for Go gateway requests (URL path `/zen/go/`). When the flag is on and `delta.content` is empty but `reasoning_content` exists, the content is emitted as visible `LanguageModelTextPart` instead of being swallowed as a thinking part. Zen gateway unaffected. Fixes the symptom where MiMo 2.5's response appeared as "thinking looping" in the Copilot Chat UI. +- **`[Streaming]` Reasoning loop detection — auto-suppress stuck MiMo output (#36).** Even with the `treatReasoningAsContent` workaround, MiMo can generate identical reasoning fragments indefinitely (the model-level loop). The extractor now has two guards: (1) if total reasoning-as-content exceeds 2000 chars without a `content` field appearing, output is suppressed; (2) if the same 40-char suffix repeats across 6+ consecutive reasoning chunks, output is suppressed. A visible warning `[MiMo seems stuck in a reasoning loop — output suppressed]` is emitted instead of the looping text. The `budget_tokens` cap then stops the stream silently. ### Added diff --git a/docs/devlog.md b/docs/devlog.md index 7eac5a1..96b89f5 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -7,10 +7,11 @@ | Field | Value | |-------|-------| -| **Last Session** | 2026-07-23 (Session 2) | -| **Worked On** | MiMo 2.5 infinite thinking loop (#36). User reported MiMo 2.5's reasoning enters an infinite loop — same thinking content repeated 30+ times, user blocked until 10-min total timeout. Initial fix added `budget_tokens` cap per effort level (low=8K, medium=16K, high=32K). User questioned why looping happens even with cap — deeper investigation revealed **two distinct root causes**: (1) opencode-go gateway bug #37635 — ALL streaming response text goes into `reasoning_content` instead of `content`, causing extension to emit everything as thinking parts, (2) MiMo model itself sometimes fails to converge. Web research confirmed #37635 (5 days old, affects ALL Go gateway models: deepseek, kimi, glm, mimo, minimax, qwen). Built a `treatReasoningAsContent` workaround in `OpenAiResponseExtractor` — when URL is `/zen/go/` and `content` is empty but `reasoning_content` exists, emit as visible text. Zen gateway unaffected. Created `docs/issues/36-20260723-mimo-thinking-infinite-loop.md` with full analysis + upstream issue references. Updated CHANGELOG [Unreleased]. Branch: `fix/mimo-thinking-budget`. | -| **Stopped At** | Ready to push `fix/mimo-thinking-budget` (1 commit: `52af4b3` fix + workaround + docs). User asked to update docs, CHANGELOG, and devlog before pushing. | -| **Next Action** | → Push branch → user decides whether to merge via merge commit or keep separate from #78 branch. | +| **Last Session** | 2026-07-23 (Session 3) | +| **Worked On** | Iteration 3 of MiMo thinking fix (#36). After 0.4.2 VSIX build + install, user tested MiMo 2.5 and reported "masih parah" (still bad). The `budget_tokens` + `treatReasoningAsContent` fixes only changed WHERE the text appears and capped token count, but the MODEL-LEVEL loop still happened until budget ran out. Added **reasoning loop detection** in `OpenAiResponseExtractor`: two guards — (1) char budget (2000 reasoning-as-content chars triggers suppression), (2) suffix repetition guard (same 40-char suffix on 6+ consecutive chunks). When triggered, further reasoning is suppressed and a visible warning `[MiMo seems stuck in a reasoning loop — output suppressed]` is emitted. Rebuilt + reinstalled VSIX. | +| **Stopped At** | Ready to push `fix/mimo-thinking-budget` (2 commits: `52af4b3` + `4a7c380` docs, need 3rd commit for loop detection). | +| **Next Action** | → Commit loop detection changes → push branch. | +| **Open Issues** | (1)-(9) same as before. Update (10): log spam from agent-host provider still high (#36 debugging showed it). | | **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) #37635 upstream — still open, assigned to MrMushrooooom. (9) #78 branch not yet pushed — split focus between #78 and #36. | --- diff --git a/src/streaming.ts b/src/streaming.ts index aab9649..9d2decb 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -108,6 +108,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} reasoning loop detected — output suppressed after ~${extractor.reasoningAsContentEmittedChars} chars. Try setting thinking to "Off" 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.`, @@ -860,6 +865,23 @@ class OpenAiResponseExtractor { */ private consecutiveRepeatCount = 0; private lastReasoningAnchor = ""; + /** + * Track total reasoning chars emitted as visible text via the Go gateway + * workaround (#37635). When this exceeds REASONING_AS_CONTENT_MAX_CHARS + * without any `content` field appearing, the model is likely stuck in a + * reasoning loop. Further reasoning emissions are suppressed. + */ + private reasoningAsContentChars = 0; + private _reasoningLoopSuppressed = false; + private reasoningLoopWarningEmitted = false; + private static readonly REASONING_AS_CONTENT_MAX_CHARS = 2000; + /** + * 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?: ( @@ -907,6 +929,16 @@ class OpenAiResponseExtractor { return this.totalReasoningChars; } + /** Whether the Go gateway reasoning loop suppression was triggered. */ + get reasoningLoopSuppressed(): boolean { + return this._reasoningLoopSuppressed; + } + + /** Total reasoning chars emitted as visible text via Go gateway workaround. */ + get reasoningAsContentEmittedChars(): number { + return this.reasoningAsContentChars; + } + /** * Accumulate reasoning for tool-call replication, and — when the thinking * part API is available — stream it live to the Copilot Chat UI. @@ -954,11 +986,13 @@ class OpenAiResponseExtractor { if (reasoning) { // Workaround for opencode-go gateway bug (#37635): when // treatReasoningAsContent is true and delta.content is empty, - // the model's response was placed in reasoni ng_content by the + // the model's response was placed in reasoning_content by the // gateway. Emit as visible text instead of thinking. if (this.treatReasoningAsContent && !visible && text.length === 0) { - this.emittedTextLength += reasoning.length; - parts.push(new vscode.LanguageModelTextPart(reasoning)); + if (!this.shouldSuppressReasoningEmit(reasoning)) { + this.emittedTextLength += reasoning.length; + parts.push(new vscode.LanguageModelTextPart(reasoning)); + } } else { this.handleReasoning(reasoning); } @@ -982,8 +1016,10 @@ class OpenAiResponseExtractor { // Same workaround for message block (Go gateway may include both // delta and message in the same chunk). if (this.treatReasoningAsContent && !visible && text.length === 0) { - this.emittedTextLength += reasoning.length; - parts.push(new vscode.LanguageModelTextPart(reasoning)); + if (!this.shouldSuppressReasoningEmit(reasoning)) { + this.emittedTextLength += reasoning.length; + parts.push(new vscode.LanguageModelTextPart(reasoning)); + } } else { this.handleReasoning(reasoning); } @@ -1011,10 +1047,75 @@ class OpenAiResponseExtractor { return this.thinkFilter.process(text); } + /** + * Check whether the current reasoning chunk should be suppressed due to a + * detected loop. + * + * Two independent guards: + * 1. **Char budget** — if total reasoning-as-content exceeds 2000 chars + * without a single `content` field, the model is probably stuck. + * 2. **Suffix repetition** — if the last 40-char suffix of a reasoning + * fragment matches the previous fragment's suffix for 6+ consecutive + * chunks, the model is in a word-level repetition loop. + * + * When either guard triggers, `reasoningLoopSuppressed` is set and a + * one-time warning is emitted as a visible text part. + * + * @returns `true` if the chunk should be suppressed (not emitted). + */ + private shouldSuppressReasoningEmit(chunk: string): boolean { + if (this._reasoningLoopSuppressed) { + return true; + } + + // --- Guard 1: total char budget --- + this.reasoningAsContentChars += chunk.length; + if (this.reasoningAsContentChars > OpenAiResponseExtractor.REASONING_AS_CONTENT_MAX_CHARS) { + this._reasoningLoopSuppressed = true; + } + + // --- Guard 2: suffix repetition --- + if (!this._reasoningLoopSuppressed && chunk.length >= 10) { + const suffix = chunk.slice(-40); + // Compare with the most recently stored suffix + const lastSuffix = this.reasoningFragmentSuffixes.at(-1); + if (lastSuffix !== undefined && suffix === lastSuffix) { + this.reasoningFragmentSuffixes.push(suffix); + if (this.reasoningFragmentSuffixes.length >= OpenAiResponseExtractor.REASONING_LOOP_SUFFIX_MATCHES) { + this._reasoningLoopSuppressed = true; + } + } else { + // Reset: suffix changed (model made progress) + this.reasoningFragmentSuffixes.length = 0; + this.reasoningFragmentSuffixes.push(suffix); + } + } + + if (this._reasoningLoopSuppressed && !this.reasoningLoopWarningEmitted) { + this.reasoningLoopWarningEmitted = true; + // Don't actually suppress here — the caller handles that via return value. + // The warning will be emitted as a text part in flushReasoningFallback. + } + + return this._reasoningLoopSuppressed; + } + flushReasoningFallback( progress: vscode.Progress, localRequestId?: string, ): void { + // Emit a visible warning if the reasoning loop was suppressed + if (this._reasoningLoopSuppressed && !this.reasoningLoopWarningEmitted) { + this.reasoningLoopWarningEmitted = true; + const warning = "[MiMo seems stuck in a reasoning loop — 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(); From baa4337180f7304b91a0224ea3642da6a21e3f03 Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Thu, 23 Jul 2026 13:16:44 +0700 Subject: [PATCH 4/4] fix(streaming): stabilize MiMo thinking loop fix + revert regressions (#36) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause was two distinct problems: 1. Go gateway bug #37635 — ALL streaming output goes into reasoning_content instead of content. When MiMo thinking is OFF, the answer text gets swallowed by the thinking panel. 2. Model-level reasoning loop — MiMo 2.5 stuck in self-referential CoT, generating same fragments indefinitely. Three-layer fix applied: - budget_tokens cap per effort level (8K/16K/32K) in thinking.ts - treatReasoningAsContent workaround: ONLY activates when Go gateway AND reasoning_effort is NOT in body (MiMo thinking OFF). When thinking IS on, reasoning_content is genuine CoT → thinking panel. - Suffix-repetition detection in handleReasoning(): same 40-char suffix 6+ times → suppress thinking parts + emit warning. Key regression fixes in this commit: - Removed contentAfterReasoning guard (DeepSeek/GLM/Kimi legitimately use reasoning_content then content — this is normal, not degradation) - Removed shouldSuppressTextEmit (false-positived on all reasoning models, not just MiMo) - Reverted extractStreamParts delta block to main behavior Upstream issue #37635 still open (assigned MrMushrooooom). Workaround can be removed when gateway bug is fixed server-side. --- CHANGELOG.md | 19 +- docs/devlog.md | 34 ++- ...36-20260723-mimo-thinking-infinite-loop.md | 193 +++++++---------- src/streaming.ts | 204 +++++++++--------- 4 files changed, 214 insertions(+), 236 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d86baf0..046cda4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,21 +10,20 @@ _No unreleased changes yet._ ### Added -- **`[Thinking]` `budget_tokens` cap for MiMo thinking payload (#36).** MiMo 2.5 / 2.5-Pro's reasoning can enter an infinite loop when `reasoning_effort` is set without a token budget. Without a cap the model keeps generating reasoning tokens indefinitely — stream stays active (idle timeout never fires), user blocked for up to 10 min (total timeout). Each effort level now sends a `budget_tokens` alongside `reasoning_effort`: `low` → 8,192, `medium` → 16,384, `high` → 32,768. Graceful degradation via `retry.ts` handler for HTTP 400 `"budget_tokens"`. -- **`[Documentation]` Issue doc for MiMo thinking loop + Go gateway bug.** `docs/issues/36-20260723-mimo-thinking-infinite-loop.md` covers root cause analysis, `budget_tokens` fix, workaround, and upstream bug references (#37635, #35209, #36354). - -### Fixed - -- **`[Streaming]` Go gateway `reasoning_content` vs `content` mismatch workaround (#36, #37635).** The opencode-go gateway places ALL streaming response text inside `reasoning_content` instead of `content` (issue anomalyco/opencode#37635, confirmed 2026-07-23). The `OpenAiResponseExtractor` now accepts a `treatReasoningAsContent` flag — active only for Go gateway requests (URL path `/zen/go/`). When the flag is on and `delta.content` is empty but `reasoning_content` exists, the content is emitted as visible `LanguageModelTextPart` instead of being swallowed as a thinking part. Zen gateway unaffected. Fixes the symptom where MiMo 2.5's response appeared as "thinking looping" in the Copilot Chat UI. -- **`[Streaming]` Reasoning loop detection — auto-suppress stuck MiMo output (#36).** Even with the `treatReasoningAsContent` workaround, MiMo can generate identical reasoning fragments indefinitely (the model-level loop). The extractor now has two guards: (1) if total reasoning-as-content exceeds 2000 chars without a `content` field appearing, output is suppressed; (2) if the same 40-char suffix repeats across 6+ consecutive reasoning chunks, output is suppressed. A visible warning `[MiMo seems stuck in a reasoning loop — output suppressed]` is emitted instead of the looping text. The `budget_tokens` cap then stops the stream silently. - -### 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 96b89f5..90af43d 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -1,5 +1,5 @@ # 🧠 OPENCODE COPILOT CHAT DEVLOG -**Branch:** `fix/mimo-thinking-budget` | **Updated:** 2026-07-23 Asia/Jakarta | **Current Phase:** Issue #36 — MiMo 2.5 Thinking Loop ✅ Fixed, needs push +**Branch:** `fix/mimo-thinking-budget` | **Updated:** 2026-07-23 Asia/Jakarta | **Current Phase:** Issue #36 — ✅ Fixed, ready to push --- @@ -7,12 +7,32 @@ | Field | Value | |-------|-------| -| **Last Session** | 2026-07-23 (Session 3) | -| **Worked On** | Iteration 3 of MiMo thinking fix (#36). After 0.4.2 VSIX build + install, user tested MiMo 2.5 and reported "masih parah" (still bad). The `budget_tokens` + `treatReasoningAsContent` fixes only changed WHERE the text appears and capped token count, but the MODEL-LEVEL loop still happened until budget ran out. Added **reasoning loop detection** in `OpenAiResponseExtractor`: two guards — (1) char budget (2000 reasoning-as-content chars triggers suppression), (2) suffix repetition guard (same 40-char suffix on 6+ consecutive chunks). When triggered, further reasoning is suppressed and a visible warning `[MiMo seems stuck in a reasoning loop — output suppressed]` is emitted. Rebuilt + reinstalled VSIX. | -| **Stopped At** | Ready to push `fix/mimo-thinking-budget` (2 commits: `52af4b3` + `4a7c380` docs, need 3rd commit for loop detection). | -| **Next Action** | → Commit loop detection changes → push branch. | -| **Open Issues** | (1)-(9) same as before. Update (10): log spam from agent-host provider still high (#36 debugging showed it). | -| **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) #37635 upstream — still open, assigned to MrMushrooooom. (9) #78 branch not yet pushed — split focus between #78 and #36. | +| **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. --- diff --git a/docs/issues/36-20260723-mimo-thinking-infinite-loop.md b/docs/issues/36-20260723-mimo-thinking-infinite-loop.md index d22ab8b..a1e4d7a 100644 --- a/docs/issues/36-20260723-mimo-thinking-infinite-loop.md +++ b/docs/issues/36-20260723-mimo-thinking-infinite-loop.md @@ -1,76 +1,71 @@ -**Status:** ✅ Solved +**Status:** ✅ Solved (with upstream workaround) -# MiMo 2.5 — Thinking Loops Endlessly (No Token Budget Cap) +# MiMo 2.5 — Thinking Loops + Go Gateway Reasoning Leak (#36) -**Topic:** thinking / mimo / streaming / budget +**Topic:** thinking / mimo / streaming / gateway / workaround **Reported:** 2026-07-23 -**Tags:** #thinking #mimo #streaming #budget #bug +**Tags:** #thinking #mimo #streaming #gateway #workaround #bug --- ## Problem -When MiMo 2.5 (or MiMo 2.5 Pro) is used with `Thinking Effort` set to any value other than `Off`, the model's `reasoning_content` stream can enter an infinite loop — repeating the same chain-of-thought fragment indefinitely without converging to a final answer. +Two distinct but related issues affect MiMo 2.5 on the opencode-go gateway: -### Observed symptom +### Problem A — Thinking loop (model level) -The thinking panel (collapsed by default in Copilot Chat) accumulates thousands of tokens that are variations of the same incomplete thought, e.g.: +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. -``` -Now fix the Penutup body. Now fix the Penutup body. Now fix the Penutup body. -[…repeated 30+ times] -``` - -or: - -``` -Actually, I think the user just wants… -Wait, I'm looking at the previous messages… -Actually, I think the user just wants… -[…repeated indefinitely] -``` +### Problem B — All response text in `reasoning_content` (gateway bug) -### Impact +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: -- The stream is **actively generating tokens** (not idle), so `DEFAULT_STREAM_IDLE_TIMEOUT_MS` (2 min) does **not** fire. -- The total timeout (`DEFAULT_REQUEST_TIMEOUT_MS`, 10 min) eventually fires, but the user is blocked for up to 10 minutes with no response. -- Cost impact: MiMo Go pricing charges for all thinking tokens generated. +- 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 -### Why it loops +### Problem A — No token budget -MiMo models use the `@ai-sdk/openai-compatible` transport routed through `chat-completions`. The extension sent only: +MiMo uses `@ai-sdk/openai-compatible`. OpenCode's transform only sends `reasoningEffort` — no budget cap: -```json -{ "reasoning_effort": "low" | "medium" | "high" } +```typescript +// OpenCode transform.ts +return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map(effort => [effort, { reasoningEffort: effort }])) +// reasoningBudget() for @ai-sdk/openai-compatible → returns undefined ``` -Unlike Qwen (which has `thinking_budget` / `enable_thinking: false`) or Anthropic models (which have `budgetTokens`), `reasoning_effort` for `@ai-sdk/openai-compatible` models in the OpenCode transform does NOT include a `budget_tokens` cap: +### Problem B — Gateway bug (#37635) -```typescript -// OpenCode transform.ts — openai-compatible variants() -return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map(effort => [effort, { reasoningEffort: effort }])) +Confirmed via direct API test on the Go gateway: -// reasoningBudget() for @ai-sdk/openai-compatible → returns undefined (no budget support) +``` +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) ``` -Without a token budget, MiMo can generate reasoning tokens beyond any reasonable limit before converging (or failing to converge). +**Affected:** ALL opencode-go models (deepseek, kimi, glm, mimo, minimax, qwen, grok). +**Not affected:** Zen gateway (`/zen/v1/`). -### Codebase location +Related upstream issues: -- `src/thinking.ts` → `buildThinkingPayload()` MiMo branch -- `src/retry.ts` → no handler for `budget_tokens` rejection +| 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) -### `src/thinking.ts` — Add `budget_tokens` to MiMo payload +### Fix 1 — `budget_tokens` in thinking payload (`src/thinking.ts`) -Added a `budget_tokens` field alongside `reasoning_effort` to cap reasoning token generation per effort level: +Added a `budget_tokens` field alongside `reasoning_effort` to cap reasoning token generation: | Effort | `reasoning_effort` | `budget_tokens` | |--------|-------------------|-----------------| @@ -78,109 +73,73 @@ Added a `budget_tokens` field alongside `reasoning_effort` to cap reasoning toke | medium | `"medium"` | 16 384 | | high | `"high"` | 32 768 | -```typescript -// before -return { reasoning_effort: thinking.mimo }; - -// after -const mimoBudgetMap = { low: 8192, medium: 16384, high: 32768 }; -const mimoBudget = mimoBudgetMap[thinking.mimo]; -return { - reasoning_effort: thinking.mimo, - ...(mimoBudget !== undefined ? { budget_tokens: mimoBudget } : {}), -}; -``` +If the gateway rejects `budget_tokens` (HTTP 400), `retry.ts` handler removes it and retries with only `reasoning_effort`. -### `src/retry.ts` — Add `budget_tokens` rejection handler +### Fix 2 — Suffix-repetition loop detection (`src/streaming.ts`) -If the OpenCode gateway or MiMo's API returns `HTTP 400 "extra inputs are not permitted, field: 'budget_tokens'"`, the retry logic now removes `budget_tokens` and retries with only `reasoning_effort`: +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. -```typescript -{ - pattern: /extra inputs are not permitted.*budget_tokens/i, - patch: (body) => { delete next.budget_tokens; return next; }, - describe: () => "removed budget_tokens (not accepted by this model)", -} -``` +### 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. -## Fallback behavior - -If `budget_tokens` is not supported by the upstream (gateway or MiMo API): +**Critical condition:** The workaround only activates when ALL three conditions are true: -1. Gateway returns `HTTP 400` with `"extra inputs are not permitted, field: 'budget_tokens'"` -2. `analyzeHttp400ForRetry()` matches the new pattern (or the existing generic pattern) -3. Extension retries with `{ reasoning_effort: "low"|"medium"|"high" }` only (previous behavior) +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 -The fix is fully backward-compatible and gracefully degrades. +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. --- -## Workaround (if loop still occurs) - -If the user encounters a thinking loop before this fix is deployed: -1. Click the **Stop** button in Copilot Chat to cancel the request -2. Switch `Thinking Effort` to **Off** for MiMo in the model picker -3. Re-send the query - ---- +## Why surgical conditions matter -## Root Cause Deep Dive — Go Gateway Bug (#37635) +Without the condition check, the workaround would break all models: -### Discovery +| 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 | -On 2026-07-23, riset internet menemukan issue **anomalyco/opencode#37635** (5 hari lalu): - -> **"opencode-go gateway returns `reasoning_content` instead of `content` in streaming responses"** - -Reporter melakukan direct API test: +--- -``` -POST https://opencode.ai/zen/go/v1/chat/completions -{"model":"grok-4.5","messages":[...],"stream":true} -``` +## Debug logging -Hasilnya — **semua chunk streaming** dari Go gateway menggunakan `reasoning_content`, bukan `content`: +The extension logs diagnostic info to the "OpenCode" output channel: ``` -data: {"choices":[{"delta":{"role":"assistant","reasoning_content":"The"}}]} -data: {"choices":[{"delta":{"reasoning_content":" user"}]} -data: {"choices":[{"delta":{"reasoning_content":" asks"}]} -... (18 chunk reasoning_content) ... -data: {"choices":[{"delta":{"content":"2"}}]} -data: {"choices":[{"finish_reason":"stop","delta":{}}]} +[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. ``` -**Affected models:** ALL opencode-go models — mimo-v2.5, mimo-v2.5-pro, deepseek-v4-pro, kimi-k3, glm-5.1, dll. - -**Hanya Go gateway (`/zen/go/`) yang kena.** Zen gateway (`/zen/v1/`) tidak terpengaruh. +--- -Non-streaming endpoint juga OK — bug hanya di streaming. +## Fallback behavior -### Hubungan dengan thinking loop +If `budget_tokens` is not supported: -Kombinasi dua bug menghasilkan gejala "thinking looping": +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 -| # | Bug | Akibat | -|---|-----|--------| -| 1 | **#37635** — Go gateway streaming pakai `reasoning_content` untuk semua output | Extension kita emit SEMUA output sebagai `LanguageModelThinkingPart` (thinking panel) | -| 2 | **Model looping** — MiMo 2.5 kadang gagal converge dan generate teks yang sama berulang | Token thinking membengkak tanpa batas | +--- -Tanpa `budget_tokens`: model looping sampai 10 menit (total timeout). -Dengan `budget_tokens` + workaround: looping terdeteksi dan dihentikan lebih awal. +## Workaround lifecycle -### Related issues +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. -| Issue | Status | Relevance | -|-------|--------|-----------| -| [#37635](https://github.com/anomalyco/opencode/issues/37635) — Go gateway `reasoning_content` vs `content` | 🟡 Open (MrMushrooooom) | Root cause — gateway bug, server-side fix needed | -| [#35209](https://github.com/anomalyco/opencode/issues/35209) — Models enter extended thinking on simple prompts | 🟡 Open (StarpTech) | Related: thinking options not gated by model capabilities | -| [#36354](https://github.com/anomalyco/opencode/issues/36354) — MiMo / DeepSeek tool-call "Internal server error" | 🟡 Open (jlongster) | Related: reasoning_content handling broken for tool calls | +--- -## Notes +## Files changed -- The `budget_tokens` values are conservative starting points. They can be tuned based on real-world usage feedback. -- A future enhancement could expose `mimoBudget` as a user-configurable setting (similar to `qwenBudget`) via the thinking picker. -- A stream-level reasoning guard (abort if `totalReasoningChars` exceeds threshold) was considered but deferred — the `budget_tokens` approach is preferable as it prevents token generation at the model level rather than after the fact. -- The `treatReasoningAsContent` workaround applies to ALL Go gateway models, not just MiMo. It can be removed once upstream #37635 is fixed. +| 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/streaming.ts b/src/streaming.ts index 9d2decb..f9fc510 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -82,17 +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 places - // ALL streaming response text inside reasoning_content instead of content. - // Detect via URL path (opencode.ai/zen/go/ vs opencode.ai/zen/). + // 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"], - /* treatReasoningAsContent */ isGoGateway, + options.output, + treatReasoningAsContent, ); await streamOpenCodeResponse({ @@ -110,7 +123,7 @@ export async function streamChatCompletions( ); if (extractor.reasoningLoopSuppressed) { options.output?.appendLine( - `[warn] model=${options.modelId} reasoning loop detected — output suppressed after ~${extractor.reasoningAsContentEmittedChars} chars. Try setting thinking to "Off" or use a different model.`, + `[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) { @@ -861,20 +874,14 @@ class OpenAiResponseExtractor { private totalReasoningChars = 0; /** - * Reasoning repeat-detection state. - */ - private consecutiveRepeatCount = 0; - private lastReasoningAnchor = ""; - /** - * Track total reasoning chars emitted as visible text via the Go gateway - * workaround (#37635). When this exceeds REASONING_AS_CONTENT_MAX_CHARS - * without any `content` field appearing, the model is likely stuck in a - * reasoning loop. Further reasoning emissions are suppressed. + * 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 reasoningAsContentChars = 0; private _reasoningLoopSuppressed = false; private reasoningLoopWarningEmitted = false; - private static readonly REASONING_AS_CONTENT_MAX_CHARS = 2000; + 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 @@ -883,6 +890,8 @@ class OpenAiResponseExtractor { private readonly reasoningFragmentSuffixes: string[] = []; private static readonly REASONING_LOOP_SUFFIX_MATCHES = 6; + + constructor( private readonly onReasoningContent?: ( toolCallIds: string[], @@ -898,21 +907,23 @@ 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 places ALL streaming response text inside - * `reasoning_content` instead of `content` for every chunk. When this - * flag is `true` and `extractTextFromDelta(delta)` returns empty but - * `extractReasoningFromDelta(delta)` returns non-empty content, the - * reasoning is emitted as visible text (LanguageModelTextPart) instead - * of as a thinking part, preventing the response from being swallowed - * into the thinking panel. + * 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 active for Go-gateway requests (URL includes `/zen/go/`). - * - Reasoning surfacing via LanguageModelThinkingPart is suppressed - * while this flag is set — the text IS the response, not CoT. + * - 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, ) {} @@ -929,20 +940,19 @@ class OpenAiResponseExtractor { return this.totalReasoningChars; } - /** Whether the Go gateway reasoning loop suppression was triggered. */ + /** Whether the reasoning loop suppression was triggered. */ get reasoningLoopSuppressed(): boolean { return this._reasoningLoopSuppressed; } - /** Total reasoning chars emitted as visible text via Go gateway workaround. */ - get reasoningAsContentEmittedChars(): number { - return this.reasoningAsContentChars; - } - /** * 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 { @@ -951,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. @@ -960,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 []; @@ -984,12 +1046,12 @@ class OpenAiResponseExtractor { } const reasoning = extractReasoningFromDelta(delta); if (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 instead of thinking. + // 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.shouldSuppressReasoningEmit(reasoning)) { + if (!this.shouldSuppressThinkingEmit(reasoning)) { this.emittedTextLength += reasoning.length; parts.push(new vscode.LanguageModelTextPart(reasoning)); } @@ -1013,16 +1075,7 @@ class OpenAiResponseExtractor { } const reasoning = extractReasoningFromDelta(message); if (reasoning) { - // Same workaround for message block (Go gateway may include both - // delta and message in the same chunk). - if (this.treatReasoningAsContent && !visible && text.length === 0) { - if (!this.shouldSuppressReasoningEmit(reasoning)) { - this.emittedTextLength += reasoning.length; - parts.push(new vscode.LanguageModelTextPart(reasoning)); - } - } else { - this.handleReasoning(reasoning); - } + this.handleReasoning(reasoning); } this.collectOpenAiToolCalls(message.tool_calls); } @@ -1047,67 +1100,14 @@ class OpenAiResponseExtractor { return this.thinkFilter.process(text); } - /** - * Check whether the current reasoning chunk should be suppressed due to a - * detected loop. - * - * Two independent guards: - * 1. **Char budget** — if total reasoning-as-content exceeds 2000 chars - * without a single `content` field, the model is probably stuck. - * 2. **Suffix repetition** — if the last 40-char suffix of a reasoning - * fragment matches the previous fragment's suffix for 6+ consecutive - * chunks, the model is in a word-level repetition loop. - * - * When either guard triggers, `reasoningLoopSuppressed` is set and a - * one-time warning is emitted as a visible text part. - * - * @returns `true` if the chunk should be suppressed (not emitted). - */ - private shouldSuppressReasoningEmit(chunk: string): boolean { - if (this._reasoningLoopSuppressed) { - return true; - } - - // --- Guard 1: total char budget --- - this.reasoningAsContentChars += chunk.length; - if (this.reasoningAsContentChars > OpenAiResponseExtractor.REASONING_AS_CONTENT_MAX_CHARS) { - this._reasoningLoopSuppressed = true; - } - - // --- Guard 2: suffix repetition --- - if (!this._reasoningLoopSuppressed && chunk.length >= 10) { - const suffix = chunk.slice(-40); - // Compare with the most recently stored suffix - const lastSuffix = this.reasoningFragmentSuffixes.at(-1); - if (lastSuffix !== undefined && suffix === lastSuffix) { - this.reasoningFragmentSuffixes.push(suffix); - if (this.reasoningFragmentSuffixes.length >= OpenAiResponseExtractor.REASONING_LOOP_SUFFIX_MATCHES) { - this._reasoningLoopSuppressed = true; - } - } else { - // Reset: suffix changed (model made progress) - this.reasoningFragmentSuffixes.length = 0; - this.reasoningFragmentSuffixes.push(suffix); - } - } - - if (this._reasoningLoopSuppressed && !this.reasoningLoopWarningEmitted) { - this.reasoningLoopWarningEmitted = true; - // Don't actually suppress here — the caller handles that via return value. - // The warning will be emitted as a text part in flushReasoningFallback. - } - - return this._reasoningLoopSuppressed; - } - flushReasoningFallback( progress: vscode.Progress, localRequestId?: string, ): void { - // Emit a visible warning if the reasoning loop was suppressed + // Emit a visible warning if a reasoning loop was detected and suppressed if (this._reasoningLoopSuppressed && !this.reasoningLoopWarningEmitted) { this.reasoningLoopWarningEmitted = true; - const warning = "[MiMo seems stuck in a reasoning loop — output suppressed]"; + const warning = "[Reasoning loop detected — thinking output suppressed]"; reportProgressPart( localRequestId, progress,