From f4169d4ebfa0202529d2752b3519cc44587ac2cc Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 12:12:58 +0800 Subject: [PATCH 01/22] refactor(thinking): per-provider strategy classes with explicit config provenance --- scripts/validate-models.ts | 12 +- src/test/thinking.test.ts | 416 +++++++++++++++++---------- src/thinking.ts | 568 ++----------------------------------- src/thinking/base.ts | 75 +++++ src/thinking/deepseek.ts | 53 ++++ src/thinking/fallback.ts | 39 +++ src/thinking/glm.ts | 53 ++++ src/thinking/kimi.ts | 81 ++++++ src/thinking/mimo.ts | 72 +++++ src/thinking/minimax.ts | 54 ++++ src/thinking/openai.ts | 57 ++++ src/thinking/payload.ts | 26 ++ src/thinking/provider.ts | 77 +++++ src/thinking/qwen.ts | 103 +++++++ src/thinking/resolve.ts | 66 +++++ src/thinking/schema.ts | 106 +++++++ src/thinking/types.ts | 51 ++++ 17 files changed, 1207 insertions(+), 702 deletions(-) create mode 100644 src/thinking/base.ts create mode 100644 src/thinking/deepseek.ts create mode 100644 src/thinking/fallback.ts create mode 100644 src/thinking/glm.ts create mode 100644 src/thinking/kimi.ts create mode 100644 src/thinking/mimo.ts create mode 100644 src/thinking/minimax.ts create mode 100644 src/thinking/openai.ts create mode 100644 src/thinking/payload.ts create mode 100644 src/thinking/provider.ts create mode 100644 src/thinking/qwen.ts create mode 100644 src/thinking/resolve.ts create mode 100644 src/thinking/schema.ts create mode 100644 src/thinking/types.ts diff --git a/scripts/validate-models.ts b/scripts/validate-models.ts index f948dfd..35ad2bd 100644 --- a/scripts/validate-models.ts +++ b/scripts/validate-models.ts @@ -3,7 +3,7 @@ * validate-models.ts — Comprehensive model parameter validation suite. * * Reuses the EXACT same logic as the extension: - * - buildThinkingPayload() from thinking.ts + * - buildPayload() from the thinking provider strategy * - resolveModelRouting() from routing.ts * - buildOpenCodeGatewayAuthHeaders() from openCodeAuth.ts * @@ -16,7 +16,7 @@ */ import { parseArgs } from "node:util"; -import { buildThinkingPayload, type ThinkingSettings } from "../src/thinking.js"; +import { thinkingProviderFor, type ThinkingSettings } from "../src/thinking.js"; import { resolveModelRouting } from "../src/routing.js"; import { buildOpenCodeGatewayAuthHeaders } from "../src/openCodeAuth.js"; @@ -118,7 +118,7 @@ function detectFamily(id: string): string { } // --------------------------------------------------------------------------- -// Build test parameters using extension's buildThinkingPayload +// Build test parameters using the extension's thinking provider strategy // --------------------------------------------------------------------------- import { THINKING_DEFAULTS } from "../src/config.js"; @@ -218,9 +218,9 @@ async function testParameter(model: ModelInfo, test: ParamTest, apiKey: string): // Use extension's auth headers const authHeaders = buildOpenCodeGatewayAuthHeaders(routing.endpointKind, apiKey); - // Build thinking payload using extension's buildThinkingPayload + // Build thinking payload using the extension's thinking provider strategy const thinking: ThinkingSettings = { ...DEFAULT_SETTINGS, ...test.settings }; - const thinkingPayload = buildThinkingPayload(model.id, thinking, test.hasImageInput); + const thinkingPayload = thinkingProviderFor(model.id).buildPayload(thinking, { hasImageInput: test.hasImageInput }); // Build the full request body exactly as the extension would const body: Record = { @@ -477,7 +477,7 @@ async function main() { if (DRY_RUN) { const summaries = tests.map((t) => { const thinking: ThinkingSettings = { ...DEFAULT_SETTINGS, ...t.settings }; - const payload = buildThinkingPayload(model.id, thinking, t.hasImageInput); + const payload = thinkingProviderFor(model.id).buildPayload(thinking, { hasImageInput: t.hasImageInput }); const fields = Object.keys(payload).filter((k) => k !== "model"); return `${t.name} → ${fields.length > 0 ? JSON.stringify(payload) : "(no thinking params)"}`; }); diff --git a/src/test/thinking.test.ts b/src/test/thinking.test.ts index 9dba0ae..6abee99 100644 --- a/src/test/thinking.test.ts +++ b/src/test/thinking.test.ts @@ -2,12 +2,13 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { bodyRequestsThinking, - buildThinkingPayload, - buildFamilyThinkingSchema, - applyRequestThinkingOverride, + extractThinkingOverride, + resolveThinkingConfig, thinkingFamily, + thinkingProviderFor, type ThinkingSettings, } from "../thinking.js"; +import { THINKING_DEFAULTS } from "../config.js"; /** Baseline settings used across tests — mirrors the default workspace config. */ const defaultSettings: ThinkingSettings = { @@ -21,81 +22,93 @@ const defaultSettings: ThinkingSettings = { mimo: "off", }; +/** Minimal reasoning-capable metadata used for schema tests. */ +const reasoningMetadata = { + reasoning: true, + reasoningOptions: [{ type: "effort" as const, values: ["high", "max"] }], + contextWindow: 202752, + maxOutputTokens: 32768, + supportsVision: false, + supportsAudio: false, + supportsVideo: false, + supportsPdf: false, + source: "models.dev" as const, +}; + /** - * Unit tests for the Kimi K2.7-code thinking fix (issue #25). - * - * ROOT CAUSE: - * The extension sent `thinking: { type: "disabled" }` when the user kept the - * default `kimi: "off"` setting. K2.7-code rejects "disabled" with HTTP 400: - * "invalid thinking: only type=enabled is allowed for this model" - * - * FIX: buildThinkingPayload special-cases /^kimi-k2\.7/i to always emit - * { type: "enabled", keep: "all" } regardless of the user's thinking setting. + * Kimi K2.7-code thinking fix (issue #25): + * the payload must always emit { thinking: { type: "enabled", keep: "all" } } + * and the resolved setting is forced on — "disabled" is rejected with HTTP 400. */ -describe("buildThinkingPayload — kimi-k2.7-code (issue #25)", () => { +describe("KimiThinking — kimi-k2.7-code (issue #25)", () => { it("always emits { type: 'enabled', keep: 'all' } even when thinking.kimi is 'off'", () => { - const payload = buildThinkingPayload("kimi-k2.7-code", { ...defaultSettings, kimi: "off" }); + const payload = thinkingProviderFor("kimi-k2.7-code").buildPayload({ ...defaultSettings, kimi: "off" }); assert.deepEqual(payload, { thinking: { type: "enabled", keep: "all" } }); }); it("emits { type: 'enabled', keep: 'all' } when thinking.kimi is 'on'", () => { - const payload = buildThinkingPayload("kimi-k2.7-code", { ...defaultSettings, kimi: "on" }); + const payload = thinkingProviderFor("kimi-k2.7-code").buildPayload({ ...defaultSettings, kimi: "on" }); assert.deepEqual(payload, { thinking: { type: "enabled", keep: "all" } }); }); it("matches kimi-k2.7-code-highspeed variant too (same model, faster output)", () => { - const payload = buildThinkingPayload("kimi-k2.7-code-highspeed", defaultSettings); + const payload = thinkingProviderFor("kimi-k2.7-code-highspeed").buildPayload(defaultSettings); assert.deepEqual(payload, { thinking: { type: "enabled", keep: "all" } }); }); + + it("forces kimi='on' through resolve even when override requests 'off'", () => { + const resolved = resolveThinkingConfig({ + modelId: "kimi-k2.7-code", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "off" }, + }); + assert.equal(resolved.settings.kimi, "on"); + }); + + it("forces kimi='on' even with no override at all (defensive against stale cache)", () => { + const resolved = resolveThinkingConfig({ modelId: "kimi-k2.7-code", workspace: defaultSettings }); + assert.equal(resolved.settings.kimi, "on"); + }); }); -describe("buildThinkingPayload — regression safety for other kimi models", () => { +describe("KimiThinking — other kimi models", () => { it("kimi-k2.6 with kimi='off' emits { type: 'disabled' } (still accepts disabled)", () => { - const payload = buildThinkingPayload("kimi-k2.6", { ...defaultSettings, kimi: "off" }); + const payload = thinkingProviderFor("kimi-k2.6").buildPayload({ ...defaultSettings, kimi: "off" }); assert.deepEqual(payload, { thinking: { type: "disabled" } }); }); it("kimi-k2.6 with kimi='on' emits { type: 'enabled' }", () => { - const payload = buildThinkingPayload("kimi-k2.6", { ...defaultSettings, kimi: "on" }); + const payload = thinkingProviderFor("kimi-k2.6").buildPayload({ ...defaultSettings, kimi: "on" }); assert.deepEqual(payload, { thinking: { type: "enabled" } }); }); it("kimi-k2.5 with kimi='off' emits { type: 'disabled' }", () => { - const payload = buildThinkingPayload("kimi-k2.5", { ...defaultSettings, kimi: "off" }); + const payload = thinkingProviderFor("kimi-k2.5").buildPayload({ ...defaultSettings, kimi: "off" }); assert.deepEqual(payload, { thinking: { type: "disabled" } }); }); -}); -describe("buildThinkingPayload — other families unchanged", () => { - it("deepseek with 'off' emits empty object (no reasoning_effort)", () => { - const payload = buildThinkingPayload("deepseek-v4-pro", { ...defaultSettings, deepseek: "off" }); - assert.deepEqual(payload, {}); - }); - - it("deepseek with 'high' emits reasoning_effort", () => { - const payload = buildThinkingPayload("deepseek-v4-pro", { ...defaultSettings, deepseek: "high" }); - assert.deepEqual(payload, { reasoning_effort: "high" }); - }); - - it("glm with 'off' emits { type: 'disabled' }", () => { - const payload = buildThinkingPayload("glm-5", { ...defaultSettings, glm: "off" }); - assert.deepEqual(payload, { thinking: { type: "disabled" } }); + it("respects 'off' override", () => { + const resolved = resolveThinkingConfig({ + modelId: "kimi-k2.6", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "off" }, + }); + assert.equal(resolved.settings.kimi, "off"); }); - it("qwen with 'off' emits enable_thinking: false", () => { - const payload = buildThinkingPayload("qwen3.6-plus", { ...defaultSettings, qwen: "off" }); - assert.deepEqual(payload, { enable_thinking: false }); + it("respects 'on' override", () => { + const resolved = resolveThinkingConfig({ + modelId: "kimi-k2.6", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "on" }, + }); + assert.equal(resolved.settings.kimi, "on"); }); }); -/** - * Schema tests: the picker must show a single "Always On (K2.7)" option so - * users understand thinking cannot be disabled, rather than hiding the picker - * or silently forcing "on". - */ -describe("buildFamilyThinkingSchema — kimi-k2.7-code picker", () => { - it("exposes a single 'on' option with 'Always On (K2.7)' label", () => { - const schema = buildFamilyThinkingSchema("kimi-k2.7-code"); +describe("KimiThinking — picker schema", () => { + it("kimi-k2.7-code exposes a single 'on' option with 'Always On (K2.7)' label", () => { + const schema = thinkingProviderFor("kimi-k2.7-code").schema(); assert.ok(schema, "expected schema to be defined"); const reasoningEffort = schema.properties.reasoningEffort as Record; assert.deepEqual(reasoningEffort.enum, ["on"]); @@ -103,8 +116,8 @@ describe("buildFamilyThinkingSchema — kimi-k2.7-code picker", () => { assert.equal(reasoningEffort.default, "on"); }); - it("mentions the Moonshot API constraint in the description", () => { - const schema = buildFamilyThinkingSchema("kimi-k2.7-code"); + it("kimi-k2.7-code description mentions the Moonshot API constraint", () => { + const schema = thinkingProviderFor("kimi-k2.7-code").schema(); assert.ok(schema, "expected schema to be defined"); const reasoningEffort = schema.properties.reasoningEffort as Record; const descriptions = reasoningEffort.enumDescriptions as string[]; @@ -113,131 +126,126 @@ describe("buildFamilyThinkingSchema — kimi-k2.7-code picker", () => { "expected description to mention the Moonshot API constraint", ); }); -}); -describe("buildFamilyThinkingSchema — other kimi models keep off/on", () => { - it("kimi-k2.6 exposes both 'off' and 'on'", () => { - const schema = buildFamilyThinkingSchema("kimi-k2.6"); - assert.ok(schema); - const reasoningEffort = schema.properties.reasoningEffort as Record; - assert.deepEqual(reasoningEffort.enum, ["off", "on"]); - }); - - it("kimi-k2.5 exposes both 'off' and 'on'", () => { - const schema = buildFamilyThinkingSchema("kimi-k2.5"); - assert.ok(schema); - const reasoningEffort = schema.properties.reasoningEffort as Record; - assert.deepEqual(reasoningEffort.enum, ["off", "on"]); + it("kimi-k2.6 / kimi-k2.5 keep off/on", () => { + for (const id of ["kimi-k2.6", "kimi-k2.5"]) { + const schema = thinkingProviderFor(id).schema(); + assert.ok(schema); + const reasoningEffort = schema.properties.reasoningEffort as Record; + assert.deepEqual(reasoningEffort.enum, ["off", "on"]); + } }); }); -/** - * Override tests: even if VS Code caches a stale picker value (e.g. "off"), - * applyRequestThinkingOverride must force kimi="on" for K2.7-code. - */ -describe("applyRequestThinkingOverride — kimi-k2.7-code defensive force-on", () => { - it("forces kimi='on' even when override requests 'off'", () => { - const result = applyRequestThinkingOverride("kimi-k2.7-code", defaultSettings, { - reasoningEffort: "off", - }); - assert.equal(result.kimi, "on"); +describe("DeepSeekThinking — payload", () => { + it("deepseek with 'off' emits empty object (no reasoning_effort)", () => { + const payload = thinkingProviderFor("deepseek-v4-pro").buildPayload({ ...defaultSettings, deepseek: "off" }); + assert.deepEqual(payload, {}); }); - it("forces kimi='on' even when override requests 'on' (no-op but explicit)", () => { - const result = applyRequestThinkingOverride("kimi-k2.7-code", defaultSettings, { - reasoningEffort: "on", - }); - assert.equal(result.kimi, "on"); + it("deepseek with 'high' emits reasoning_effort", () => { + const payload = thinkingProviderFor("deepseek-v4-pro").buildPayload({ ...defaultSettings, deepseek: "high" }); + assert.deepEqual(payload, { reasoning_effort: "high" }); }); - it("forces kimi='on' when override is empty (defensive against stale cache)", () => { - const result = applyRequestThinkingOverride("kimi-k2.7-code", defaultSettings, {}); - assert.equal(result.kimi, "on"); + it("deepseek with 'max' emits reasoning_effort", () => { + const payload = thinkingProviderFor("deepseek-v4-pro").buildPayload({ ...defaultSettings, deepseek: "max" }); + assert.deepEqual(payload, { reasoning_effort: "max" }); }); }); -describe("applyRequestThinkingOverride — other kimi models respect override", () => { - it("kimi-k2.6 respects 'off' override", () => { - const result = applyRequestThinkingOverride("kimi-k2.6", defaultSettings, { - reasoningEffort: "off", - }); - assert.equal(result.kimi, "off"); +describe("DeepSeekThinking — display (native reasoning model)", () => { + it("never treats reasoning_content as visible content, even with thinking off on the Go gateway", () => { + const provider = thinkingProviderFor("deepseek-v4-flash"); + assert.equal( + provider.treatReasoningAsContent("https://opencode.ai/zen/go/v1/chat/completions", { ...defaultSettings, deepseek: "off" }), + false, + ); + assert.equal( + provider.treatReasoningAsContent("https://opencode.ai/zen/go/v1/chat/completions", { ...defaultSettings, deepseek: "max" }), + false, + ); }); - it("kimi-k2.6 respects 'on' override", () => { - const result = applyRequestThinkingOverride("kimi-k2.6", defaultSettings, { - reasoningEffort: "on", - }); - assert.equal(result.kimi, "on"); + it("requestsThinking reflects the payload", () => { + const provider = thinkingProviderFor("deepseek-v4-flash"); + assert.equal(provider.requestsThinking({ ...defaultSettings, deepseek: "off" }), false); + assert.equal(provider.requestsThinking({ ...defaultSettings, deepseek: "high" }), true); }); }); -describe("thinkingFamily — detection", () => { - it("classifies kimi-k2.7-code as 'kimi'", () => { - assert.equal(thinkingFamily("kimi-k2.7-code"), "kimi"); +describe("MimoThinking — payload + display (native reasoning model)", () => { + it("mimo 'off' emits empty object", () => { + const payload = thinkingProviderFor("mimo-v2.5").buildPayload({ ...defaultSettings, mimo: "off" }); + assert.deepEqual(payload, {}); }); - it("classifies kimi-k2.6 as 'kimi'", () => { - assert.equal(thinkingFamily("kimi-k2.6"), "kimi"); + it("mimo 'medium' emits reasoning_effort + budget_tokens", () => { + const payload = thinkingProviderFor("mimo-v2.5").buildPayload({ ...defaultSettings, mimo: "medium" }); + assert.deepEqual(payload, { reasoning_effort: "medium", budget_tokens: 16384 }); }); - it("returns null for unknown prefixes", () => { - assert.equal(thinkingFamily("unknown-model"), null); + it("never surfaces reasoning_content as visible text (native reasoning model)", () => { + const provider = thinkingProviderFor("mimo-v2.5"); + const goUrl = "https://opencode.ai/zen/go/v1/chat/completions"; + assert.equal(provider.treatReasoningAsContent(goUrl, { ...defaultSettings, mimo: "off" }), false); + assert.equal(provider.treatReasoningAsContent(goUrl, { ...defaultSettings, mimo: "high" }), false); + assert.equal( + provider.treatReasoningAsContent("https://opencode.ai/zen/v1/chat/completions", { ...defaultSettings, mimo: "off" }), + false, + ); }); }); -/** - * Tests for GLM models with effort-style reasoning (issue #61). - * - * models.dev reports: - * glm-5.2 → reasoning_options = [{ type: "effort", values: ["high", "max"] }] - * glm-5.1 → no reasoning_options (toggle-based) - * glm-5 → no reasoning_options (toggle-based) - * - * The new "high"/"max" values must map to thinking enabled in the payload, - * and the per-model picker should expose only the relevant options. - */ -describe("buildThinkingPayload — GLM with effort values (issue #61)", () => { +describe("GLMThinking — payload (issue #61)", () => { it("glm-5.2 with glm='high' emits reasoning_effort: 'high'", () => { - const payload = buildThinkingPayload("glm-5.2", { ...defaultSettings, glm: "high" }); + const payload = thinkingProviderFor("glm-5.2").buildPayload({ ...defaultSettings, glm: "high" }); assert.deepEqual(payload, { reasoning_effort: "high" }); }); it("glm-5.2 with glm='max' emits reasoning_effort: 'max'", () => { - const payload = buildThinkingPayload("glm-5.2", { ...defaultSettings, glm: "max" }); + const payload = thinkingProviderFor("glm-5.2").buildPayload({ ...defaultSettings, glm: "max" }); assert.deepEqual(payload, { reasoning_effort: "max" }); }); it("glm-5.2 with glm='off' emits thinking disabled", () => { - const payload = buildThinkingPayload("glm-5.2", { ...defaultSettings, glm: "off" }); + const payload = thinkingProviderFor("glm-5.2").buildPayload({ ...defaultSettings, glm: "off" }); assert.deepEqual(payload, { thinking: { type: "disabled" } }); }); it("glm-5 (toggle-only) with glm='high' sends reasoning_effort (gateway resolves)", () => { - const payload = buildThinkingPayload("glm-5", { ...defaultSettings, glm: "high" }); + const payload = thinkingProviderFor("glm-5").buildPayload({ ...defaultSettings, glm: "high" }); assert.deepEqual(payload, { reasoning_effort: "high" }); }); +}); - it("glm-5 (toggle-only) with glm='off' emits thinking disabled", () => { - const payload = buildThinkingPayload("glm-5", { ...defaultSettings, glm: "off" }); - assert.deepEqual(payload, { thinking: { type: "disabled" } }); +describe("GLMThinking — override (issue #61)", () => { + it("accepts 'high' / 'max' / 'off' overrides for glm-5.2", () => { + for (const value of ["high", "max", "off"]) { + const resolved = resolveThinkingConfig({ + modelId: "glm-5.2", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: value }, + }); + assert.equal(resolved.settings.glm, value); + } + }); + + it("rejects invalid values like 'on' and 'medium' for glm", () => { + for (const value of ["on", "medium"]) { + const resolved = resolveThinkingConfig({ + modelId: "glm-5.2", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: value }, + }); + assert.equal(resolved.settings.glm, "off"); // stays at default + } }); }); -describe("buildFamilyThinkingSchema — GLM 5.2 with reasoning_options metadata", () => { - it("exposes off, high, max when reasoning_options has effort values", () => { - const metadata = { - reasoning: true, - reasoningOptions: [{ type: "effort" as const, values: ["high", "max"] }], - contextWindow: 202752, - maxOutputTokens: 32768, - supportsVision: false, - supportsAudio: false, - supportsVideo: false, - supportsPdf: false, - source: "models.dev" as const, - }; - const schema = buildFamilyThinkingSchema("glm-5.2", metadata); +describe("GLMThinking — picker schema", () => { + it("exposes off/high/max when reasoning_options has effort values", () => { + const schema = thinkingProviderFor("glm-5.2").schema(reasoningMetadata); assert.ok(schema, "expected schema to be defined"); const reasoningEffort = schema.properties.reasoningEffort as Record; assert.deepEqual(reasoningEffort.enum, ["off", "high", "max"]); @@ -246,7 +254,7 @@ describe("buildFamilyThinkingSchema — GLM 5.2 with reasoning_options metadata" }); it("falls back to off/high/max for GLM models without reasoning_options (no invalid 'on')", () => { - const schema = buildFamilyThinkingSchema("glm-5"); + const schema = thinkingProviderFor("glm-5").schema(); assert.ok(schema, "expected schema to be defined"); const reasoningEffort = schema.properties.reasoningEffort as Record; assert.deepEqual(reasoningEffort.enum, ["off", "high", "max"]); @@ -254,37 +262,135 @@ describe("buildFamilyThinkingSchema — GLM 5.2 with reasoning_options metadata" }); }); -describe("applyRequestThinkingOverride — GLM with effort values (issue #61)", () => { - it("accepts 'high' override for glm-5.2", () => { - const result = applyRequestThinkingOverride("glm-5.2", defaultSettings, { - reasoningEffort: "high", - }); - assert.equal(result.glm, "high"); +describe("QwenThinking — payload per endpoint", () => { + it("chat endpoint with qwen='off' emits enable_thinking: false", () => { + const payload = thinkingProviderFor("qwen3.6-plus").buildPayload({ ...defaultSettings, qwen: "off" }); + assert.deepEqual(payload, { enable_thinking: false }); }); - it("accepts 'max' override for glm-5.2", () => { - const result = applyRequestThinkingOverride("glm-5.2", defaultSettings, { - reasoningEffort: "max", - }); - assert.equal(result.glm, "max"); + it("chat endpoint with qwen='on' + budget emits enable_thinking + thinking_budget", () => { + const payload = thinkingProviderFor("qwen3.6-plus").buildPayload({ ...defaultSettings, qwen: "on", qwenBudget: "4096" }); + assert.deepEqual(payload, { enable_thinking: true, thinking_budget: 4096 }); }); - it("accepts 'off' override for glm-5.2", () => { - const result = applyRequestThinkingOverride("glm-5.2", defaultSettings, { - reasoningEffort: "off", - }); - assert.equal(result.glm, "off"); + it("messages endpoint with qwen='on' emits Anthropic thinking block", () => { + const payload = thinkingProviderFor("qwen3.6-plus").buildPayload( + { ...defaultSettings, qwen: "on", qwenBudget: "4096" }, + { endpoint: "messages" }, + ); + assert.deepEqual(payload, { thinking: { type: "enabled", budget_tokens: 4096 } }); }); - it("rejects invalid values like 'on' and 'medium' for glm", () => { - const resultOn = applyRequestThinkingOverride("glm-5.2", defaultSettings, { - reasoningEffort: "on", + it("messages endpoint with qwen='off' emits Anthropic disabled", () => { + const payload = thinkingProviderFor("qwen3.6-plus").buildPayload({ ...defaultSettings, qwen: "off" }, { endpoint: "messages" }); + assert.deepEqual(payload, { thinking: { type: "disabled" } }); + }); + + it("messages endpoint with qwen='auto' emits nothing", () => { + const payload = thinkingProviderFor("qwen3.6-plus").buildPayload({ ...defaultSettings, qwen: "auto" }, { endpoint: "messages" }); + assert.deepEqual(payload, {}); + }); +}); + +describe("OpenAiThinking / MiniMaxThinking — payload shapes", () => { + it("openai 'high' emits nested reasoning.effort (Responses API)", () => { + const payload = thinkingProviderFor("gpt-5.6-luna").buildPayload({ ...defaultSettings, openai: "high" }); + assert.deepEqual(payload, { reasoning: { effort: "high" } }); + }); + + it("openai 'off' emits nothing", () => { + const payload = thinkingProviderFor("gpt-5.6-luna").buildPayload(defaultSettings); + assert.deepEqual(payload, {}); + }); + + it("minimax-m2 on emits Anthropic enabled; minimax-m3 on emits adaptive", () => { + const m2 = thinkingProviderFor("minimax-m2.7").buildPayload({ ...defaultSettings, minimax: "on" }); + assert.deepEqual(m2, { thinking: { type: "enabled" } }); + const m3 = thinkingProviderFor("minimax-m3").buildPayload({ ...defaultSettings, minimax: "on" }); + assert.deepEqual(m3, { thinking: { type: "adaptive" } }); + }); +}); + +describe("thinkingFamily — detection", () => { + it("classifies kimi-k2.7-code as 'kimi'", () => { + assert.equal(thinkingFamily("kimi-k2.7-code"), "kimi"); + }); + + it("classifies kimi-k2.6 as 'kimi'", () => { + assert.equal(thinkingFamily("kimi-k2.6"), "kimi"); + }); + + it("returns null for unknown prefixes", () => { + assert.equal(thinkingFamily("unknown-model"), null); + }); +}); + +describe("resolveThinkingConfig — provenance & priority", () => { + it("modelConfiguration wins over the workspace default", () => { + const resolved = resolveThinkingConfig({ + modelId: "deepseek-v4-pro", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "max" }, }); - assert.equal(resultOn.glm, "off"); // stays at default - const resultMed = applyRequestThinkingOverride("glm-5.2", defaultSettings, { - reasoningEffort: "medium", + assert.equal(resolved.settings.deepseek, "max"); + assert.equal(resolved.source, "modelConfiguration"); + assert.equal(resolved.overrideApplied, true); + }); + + it("falls back to workspace when no override exists", () => { + const resolved = resolveThinkingConfig({ modelId: "deepseek-v4-pro", workspace: defaultSettings }); + assert.equal(resolved.settings.deepseek, "off"); + assert.equal(resolved.source, "workspace"); + assert.equal(resolved.overrideApplied, false); + }); + + it("a delivered modelConfiguration equal to workspace still reports modelConfiguration source", () => { + const resolved = resolveThinkingConfig({ + modelId: "deepseek-v4-pro", + workspace: defaultSettings, + modelConfiguration: { reasoningEffort: "off" }, }); - assert.equal(resultMed.glm, "off"); // stays at default + assert.equal(resolved.settings.deepseek, "off"); + assert.equal(resolved.source, "modelConfiguration"); + }); +}); + +describe("extractThinkingOverride", () => { + it("picks string thinking keys only", () => { + assert.deepEqual(extractThinkingOverride({ reasoningEffort: "max", contextSize: 131072 }), { reasoningEffort: "max" }); + }); + + it("returns undefined for empty / absent config", () => { + assert.equal(extractThinkingOverride(undefined), undefined); + assert.equal(extractThinkingOverride({}), undefined); + assert.equal(extractThinkingOverride({ contextSize: 5 }), undefined); + }); +}); + +describe("schema defaults stay aligned with THINKING_DEFAULTS", () => { + it("reasoningEffort defaults match the workspace defaults per family", () => { + const cases: Array<[string, string]> = [ + ["deepseek-v4-pro", "deepseek"], + ["glm-5.2", "glm"], + ["kimi-k2.6", "kimi"], + ["minimax-m3", "minimax"], + ["gpt-5.6-luna", "openai"], + ["qwen3.6-plus", "qwen"], + ["mimo-v2.5", "mimo"], + ]; + for (const [modelId, key] of cases) { + const schema = thinkingProviderFor(modelId).schema(); + assert.ok(schema, `expected schema for ${modelId}`); + const reasoningEffort = schema.properties.reasoningEffort as Record; + assert.equal(reasoningEffort.default, THINKING_DEFAULTS[key as keyof typeof THINKING_DEFAULTS], `default mismatch for ${modelId}`); + } + }); + + it("qwen thinkingBudget default matches THINKING_DEFAULTS.qwenBudget", () => { + const schema = thinkingProviderFor("qwen3.6-plus").schema(); + assert.ok(schema); + const budget = schema.properties.thinkingBudget as Record; + assert.equal(budget.default, THINKING_DEFAULTS.qwenBudget); }); }); diff --git a/src/thinking.ts b/src/thinking.ts index ab28331..4579aa5 100644 --- a/src/thinking.ts +++ b/src/thinking.ts @@ -1,544 +1,30 @@ /** - * Thinking / reasoning configuration for per-model families. + * Thinking system public barrel. * - * CONTRACT: - * - Pure functions only — no `vscode` import, no side effects. - * - Extracted from `extension.ts` to enable unit testing without mocking the - * VS Code API surface. - * - * INVARIANTS: - * - `buildThinkingPayload` must never emit a field the upstream API rejects. - * Each model family has its own contract; see inline comments. - * - `buildFamilyThinkingSchema` returns a plain JSON-schema-like object. The - * caller (`modelConfigurationSchema` in `extension.ts`) wraps it with the - * VS Code type annotation. - */ -import type { ResolvedModelMetadata } from "./metadata"; - -/** Per-family thinking settings stored in the workspace configuration. */ -export interface ThinkingSettings { - deepseek: "off" | "low" | "medium" | "high" | "max"; - glm: "off" | "high" | "max"; - kimi: "on" | "off"; - minimax: "off" | "on"; - openai: "off" | "low" | "medium" | "high" | "xhigh"; - qwen: "auto" | "on" | "off"; - qwenBudget: "auto" | "4096" | "16384" | "32768" | "81920"; - mimo: "off" | "low" | "medium" | "high"; -} - -/** Detected thinking family for a raw model id. */ -export type ThinkingFamily = "deepseek" | "glm" | "kimi" | "minimax" | "openai" | "qwen" | "mimo" | null; - -/** - * Detect which Thinking family a raw model id belongs to. Used both to render - * the per-model picker submenu (configurationSchema) and to map the user's - * per-request selection back to the right OpenCode request field. - */ -export function thinkingFamily(modelId: string): ThinkingFamily { - if (/^deepseek-/i.test(modelId)) return "deepseek"; - if (/^glm-/i.test(modelId)) return "glm"; - if (/^kimi-/i.test(modelId)) return "kimi"; - if (/^minimax-/i.test(modelId)) return "minimax"; - if (/^gpt-/i.test(modelId)) return "openai"; - if (/^qwen3(?:\.|-)/i.test(modelId)) return "qwen"; - if (/^mimo-/i.test(modelId)) return "mimo"; - return null; -} - -/** - * Per-family JSON-Schema describing the native model-picker controls rendered - * by VS Code 1.120. Accepts optional metadata for dynamic fallback: any model - * with `reasoning: true` in its resolved metadata gets a generic off/on schema - * even if no hardcoded family match exists. - * - * Returns a plain object; the caller adds the VS Code type annotation. - */ -export function buildFamilyThinkingSchema( - modelId: string, - metadata?: ResolvedModelMetadata, -): { properties: Record } | undefined { - const family = thinkingFamily(modelId); - const opts = metadata?.reasoningOptions; - - // --- Priority 1: explicit reasoning_options from models.dev --- - if (opts && opts.length > 0) { - // Collect unique effort values across all effort-type options - const effortValues = opts - .filter((o): o is { type: "effort"; values: string[] } => o.type === "effort" && Array.isArray(o.values) && o.values.length > 0) - .flatMap((o) => o.values) - .filter((v, i, a) => a.indexOf(v) === i); - - // Check if a toggle-type option exists - const hasToggle = opts.some((o) => o.type === "toggle"); - - // Build the enum options: - // - If toggle exists, "off" is the default (user can toggle off) - // - If effort values exist, those become additional options - // - If neither toggle nor effort, but there are options, treat as on/off - if (hasToggle || effortValues.length > 0) { - const enumOptions: string[] = []; - const enumLabels: string[] = []; - const enumDescriptions: string[] = []; - - // "off" is always the first option when toggle is present - enumOptions.push("off"); - enumLabels.push("Off"); - enumDescriptions.push("Fastest responses"); - - // Toggle-only (no effort values): add "on" for a simple off/on choice - if (hasToggle && effortValues.length === 0) { - enumOptions.push("on"); - enumLabels.push("On"); - enumDescriptions.push("Enable reasoning"); - } - - // Add effort levels - for (const v of effortValues) { - enumOptions.push(v); - // Capitalize first letter - enumLabels.push(v.charAt(0).toUpperCase() + v.slice(1)); - // Generate description - switch (v) { - case "low": - enumDescriptions.push("Faster responses with less reasoning"); - break; - case "medium": - enumDescriptions.push("Balanced reasoning and speed"); - break; - case "high": - enumDescriptions.push("Greater reasoning depth but slower"); - break; - case "xhigh": - enumDescriptions.push("Maximum reasoning depth"); - break; - case "max": - enumDescriptions.push("Maximum reasoning effort"); - break; - default: - enumDescriptions.push(`Effort: ${v}`); - } - } - - if (enumOptions.length > 0) { - const schema: Record = { - type: "string", - title: "Thinking Effort", - enum: enumOptions, - enumItemLabels: enumLabels, - enumDescriptions, - default: "off", - group: "navigation", - }; - - return { properties: { reasoningEffort: schema } }; - } - } - - // Fallthrough: if options exist but none matched, treat as reasoning enabled - // (the caller already handles reasoning:true below) - } - - // --- Priority 2: family-based hardcoded --- - if (family === "deepseek") { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "low", "medium", "high", "max"], - enumItemLabels: ["Off", "Low", "Medium", "High", "Max"], - enumDescriptions: ["Fastest responses", "Minimal reasoning", "Balanced reasoning", "More reasoning", "Maximum reasoning"], - default: "off", - group: "navigation", - }, - }, - }; - } - - if (family === "mimo") { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "low", "medium", "high"], - enumItemLabels: ["Off", "Low", "Medium", "High"], - enumDescriptions: ["Fastest responses", "Minimal reasoning", "Balanced reasoning", "Enable reasoning"], - default: "off", - group: "navigation", - }, - }, - }; - } - - // Kimi K2.7-code: thinking cannot be disabled (Moonshot API constraint). - // Expose a single informational option so users understand the model always - // reasons, rather than hiding the picker or silently forcing "on". - if (/^kimi-k2\.7/i.test(modelId)) { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["on"], - enumItemLabels: ["Always On (K2.7)"], - enumDescriptions: ["Kimi K2.7-code requires thinking enabled (Moonshot API constraint)"], - default: "on", - group: "navigation", - }, - }, - }; - } - - if (family === "glm") { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "high", "max"], - enumItemLabels: ["Off", "High", "Max"], - enumDescriptions: ["Fastest responses", "Greater reasoning depth", "Maximum reasoning effort"], - default: "off", - group: "navigation", - }, - }, - }; - } - - if (family === "openai") { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "low", "medium", "high", "xhigh"], - enumItemLabels: ["Off", "Low", "Medium", "High", "XHigh"], - enumDescriptions: [ - "Fastest responses", - "Faster responses with less reasoning", - "Balanced reasoning and speed", - "Greater reasoning depth", - "Maximum reasoning depth", - ], - default: "off", - group: "navigation", - }, - }, - }; - } - - if (family === "kimi") { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "on"], - enumItemLabels: ["Off", "On"], - enumDescriptions: ["Fastest responses", "Enable thinking"], - default: "off", - group: "navigation", - }, - }, - }; - } - - if (family === "minimax") { - // OpenCode transform.ts only defines none/thinking for minimax-m3, and - // the gateway does not expose reasoning_effort levels. On/off only. - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "on"], - enumItemLabels: ["Off", "On"], - enumDescriptions: ["Fastest responses", "Enable thinking"], - default: "off", - group: "navigation", - }, - }, - }; - } - - if (family === "qwen") { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "auto", "on"], - enumItemLabels: ["Off", "Auto", "On"], - enumDescriptions: ["Fastest responses", "Model decides", "Enable thinking"], - default: "off", - group: "navigation", - }, - thinkingBudget: { - type: "string", - title: "Thinking Budget", - enum: ["auto", "4096", "16384", "32768", "81920"], - enumItemLabels: ["Auto", "4K", "16K", "32K", "80K"], - enumDescriptions: ["Provider default", "Small budget", "Medium budget", "Large budget", "Maximum budget"], - default: "auto", - }, - }, - }; - } - - // --- Priority 3: dynamic fallback for any reasoning-capable model --- - if (metadata?.reasoning) { - return { - properties: { - reasoningEffort: { - type: "string", - title: "Thinking Effort", - enum: ["off", "on"], - enumItemLabels: ["Off", "On"], - enumDescriptions: ["Fastest responses", "Enable reasoning"], - default: "off", - group: "navigation", - }, - }, - }; - } - - return undefined; -} - -/** - * Merge per-request modelConfiguration (from the Copilot Chat submenu) onto - * the global ThinkingSettings, so the picker selection wins over the workspace - * default. Only the field for the model's own family is touched. - */ -export function applyRequestThinkingOverride( - modelId: string, - base: ThinkingSettings, - override: Record | undefined, -): ThinkingSettings { - if (!override) return base; - const family = thinkingFamily(modelId); - if (!family) return base; - - const next: ThinkingSettings = { ...base }; - const reasoningEffort = override.reasoningEffort; - const thinkingMode = override.thinkingMode; - const thinkingBudget = override.thinkingBudget; - - if (family === "deepseek" && typeof reasoningEffort === "string") { - if (["off", "low", "medium", "high", "max"].includes(reasoningEffort)) { - next.deepseek = reasoningEffort as ThinkingSettings["deepseek"]; - } - } - if (family === "glm" && typeof thinkingMode === "string") { - if (["off", "high", "max"].includes(thinkingMode)) next.glm = thinkingMode as ThinkingSettings["glm"]; - } - if (family === "glm" && typeof reasoningEffort === "string") { - if (["off", "high", "max"].includes(reasoningEffort)) next.glm = reasoningEffort as ThinkingSettings["glm"]; - } - if (family === "kimi" && typeof thinkingMode === "string") { - if (thinkingMode === "on" || thinkingMode === "off") next.kimi = thinkingMode; - } - if (family === "kimi" && typeof reasoningEffort === "string") { - if (reasoningEffort === "on" || reasoningEffort === "off") next.kimi = reasoningEffort; - } - // K2.7-code forces thinking on regardless of picker selection (defensive — - // the picker schema only exposes "on", but VS Code may cache a stale value). - if (family === "kimi" && /^kimi-k2\.7/i.test(modelId)) { - next.kimi = "on"; - } - if (family === "mimo") { - if (typeof reasoningEffort === "string" && ["off", "low", "medium", "high"].includes(reasoningEffort)) { - next.mimo = reasoningEffort as ThinkingSettings["mimo"]; - } - } - if (family === "minimax") { - if (typeof reasoningEffort === "string" && ["off", "on"].includes(reasoningEffort)) { - next.minimax = reasoningEffort as ThinkingSettings["minimax"]; - } - } - if (family === "openai") { - if (typeof reasoningEffort === "string") { - const valid = ["off", "low", "medium", "high", "xhigh"]; - if (valid.includes(reasoningEffort)) { - next.openai = reasoningEffort as ThinkingSettings["openai"]; - } - } - } - if (family === "qwen") { - if (typeof thinkingMode === "string" && (thinkingMode === "auto" || thinkingMode === "on" || thinkingMode === "off")) { - next.qwen = thinkingMode; - } - if (typeof reasoningEffort === "string" && (reasoningEffort === "auto" || reasoningEffort === "on" || reasoningEffort === "off")) { - next.qwen = reasoningEffort; - } - if (typeof thinkingBudget === "string" && ["auto", "4096", "16384", "32768", "81920"].includes(thinkingBudget)) { - next.qwenBudget = thinkingBudget as ThinkingSettings["qwenBudget"]; - } - } - return next; -} - -/** - * Maps the per-family Thinking settings to the request fields each OpenCode - * model family expects. Returns an object to spread into the request body. - * Anything returned here is merged into the OpenAI- or Anthropic-style payload. - * - * SPECIAL CASES: - * - Kimi K2.7-code: always `{ thinking: { type: "enabled", keep: "all" } }` - * regardless of user setting (Moonshot API rejects `disabled`). - */ -export function buildThinkingPayload(modelId: string, thinking: ThinkingSettings, hasImageInput = false): Record { - // Kimi K2.7-code breaking change: thinking.type only accepts "enabled" — - // passing "disabled" returns HTTP 400 ("invalid thinking: only type=enabled - // is allowed for this model"). Thinking is always on for this model. - // keep:"all" preserves reasoning_content across multi-turn conversations - // per the Moonshot API spec (default is { type: "enabled", keep: "all" }). - if (/^kimi-k2\.7/i.test(modelId)) { - return { thinking: { type: "enabled", keep: "all" } }; - } - - if (/^deepseek-/i.test(modelId)) { - if (thinking.deepseek === "off") { - return {}; - } - return { reasoning_effort: thinking.deepseek }; - } - - // OpenAI GPT 5.x models via Responses API: reasoning is a nested object. - // Supported values: "none", "minimal", "low", "medium", "high", "xhigh", "max". - // The OpenCode gateway forwards reasoning.effort to the OpenAI Responses API. - // Note: VS Code Copilot UI maps "max" → we map to "xhigh" for OpenAI. - if (/^gpt-/i.test(modelId)) { - if (thinking.openai === "off") { - return {}; - } - return { reasoning: { effort: thinking.openai } }; - } - - if (/^glm-/i.test(modelId)) { - // GLM (ZhipuAI) uses thinking: { type: "enabled" | "disabled" } format. - // The gateway's transform.ts variants() returns {} for GLM — no variants - // are exposed, meaning the gateway doesn't validate or transform GLM - // thinking parameters. We send through as-is to the upstream API. - // - // When the value is a concrete effort level (e.g. "high", "max", or future - // "low"/"medium"), send reasoning_effort directly — the gateway or upstream - // API determines support. Only "off" maps to disabled. - if (thinking.glm === "off") { - return { thinking: { type: "disabled" } }; - } - return { reasoning_effort: thinking.glm }; - } - - if (/^kimi-/i.test(modelId)) { - // Tests confirm the gateway accepts thinking: { type } for Kimi - return { thinking: { type: thinking.kimi === "on" ? "enabled" : "disabled" } }; - } - - if (/^qwen3(?:\.|-)/i.test(modelId)) { - if (thinking.qwen === "auto") { - // Let the model decide; don't send enable_thinking. Budget is only - // meaningful when thinking is active, so honor it here as well. Vision - // requests are already token-heavy; keep "auto" truly automatic so the - // provider can stay under its image quota/token limits. - if (hasImageInput) { - return {}; - } - return thinking.qwenBudget === "auto" ? {} : { thinking_budget: Number(thinking.qwenBudget) }; - } - if (thinking.qwen === "on") { - return thinking.qwenBudget === "auto" - ? { enable_thinking: true } - : { enable_thinking: true, thinking_budget: Number(thinking.qwenBudget) }; - } - return { enable_thinking: false }; - } - - 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 {}; - } - 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)) { - // OpenCode transform.ts maps minimax-m3 to thinking: { type: "disabled"|"adaptive" } - // (Anthropic-style format, not reasoning_effort). MiniMax models routed through - // the messages endpoint (m2.*) use standard Anthropic enabled/disabled. - if (thinking.minimax === "off") { - return {}; - } - if (/^minimax-m2\./i.test(modelId)) { - return { thinking: { type: "enabled" } }; - } - return { thinking: { type: "adaptive" } }; - } - - return {}; -} - -/** - * Whether a request body asks the model to think, through ANY channel the - * extension emits: `reasoning_effort`, `budget_tokens`, `enable_thinking`, - * or an Anthropic-style `thinking` block (`enabled` / `adaptive`). - * - * Used by the stream extractor to decide whether `reasoning_content` is - * genuine chain-of-thought (goes to the thinking panel) vs. the Go gateway's - * "thinking off" mislabeling (surfaced as visible text, issue #37635). - */ -export function bodyRequestsThinking(body: Record | undefined): boolean { - if (!body) return false; - if (typeof body.reasoning_effort === "string") return true; - if (typeof body.budget_tokens === "number") return true; - if (body.enable_thinking === true) return true; - if (body.thinking !== null && typeof body.thinking === "object") { - const type = (body.thinking as Record).type; - return type === "enabled" || type === "adaptive"; - } - return false; -} - -/** - * Translates Qwen thinking settings into Anthropic-native format when Qwen - * models are routed through the Anthropic messages endpoint. The gateway - * expects { type: "enabled"|"disabled" } with an optional budget_tokens field, - * matching the Anthropic thinking API contract. + * Re-exports the per-provider strategy architecture (see `./thinking/`). + * Keeps the legacy `import { ... } from "./thinking"` paths working. */ -export function buildQwenAnthropicThinkingPayload(thinking: ThinkingSettings): Record { - if (thinking.qwen === "on") { - const budget = thinking.qwenBudget === "auto" ? undefined : Number(thinking.qwenBudget); - return { - thinking: { - type: "enabled", - ...(budget !== undefined ? { budget_tokens: budget } : {}), - }, - }; - } - if (thinking.qwen === "off") { - return { thinking: { type: "disabled" } }; - } - // "auto" — let the provider decide; send no thinking directive. - return {}; -} +export { thinkingFamily, thinkingProviderFor } from "./thinking/provider"; +export type { ThinkingProvider } from "./thinking/provider"; +export { resolveThinkingConfig, extractThinkingOverride } from "./thinking/resolve"; +export type { ResolveThinkingConfigInput } from "./thinking/resolve"; +export { schemaFromReasoningOptions, genericReasoningSchema } from "./thinking/schema"; +export type { ThinkingSchema } from "./thinking/schema"; +export { bodyRequestsThinking } from "./thinking/payload"; +export { DeepSeekThinking } from "./thinking/deepseek"; +export { GlmThinking } from "./thinking/glm"; +export { KimiThinking } from "./thinking/kimi"; +export { MiniMaxThinking } from "./thinking/minimax"; +export { OpenAiThinking } from "./thinking/openai"; +export { QwenThinking } from "./thinking/qwen"; +export { MimoThinking } from "./thinking/mimo"; +export { FallbackThinking } from "./thinking/fallback"; +export type { + ThinkingSettings, + ThinkingFamily, + ThinkingSource, + ResolvedThinking, + ThinkingOverride, + BuildThinkingPayloadOptions, +} from "./thinking/types"; +// (legacy per-family implementations moved to src/thinking/ — see barrel above) diff --git a/src/thinking/base.ts b/src/thinking/base.ts new file mode 100644 index 0000000..6faf2a9 --- /dev/null +++ b/src/thinking/base.ts @@ -0,0 +1,75 @@ +/** + * Shared plumbing for per-provider thinking strategies. + * + * Each model family gets its own concrete {@link ThinkingProvider} so its + * schema, override mapping, request payload shape and display decision stay + * encapsulated. This base class only holds the common contract and small + * pure helpers — it never special-cases a provider. + * + * CONTRACT: pure only — no `vscode` import, no side effects. + */ +import type { ResolvedModelMetadata } from "../metadata"; +// Type-only import from the sibling registry module — erased at runtime, so +// there is no circular dependency between the base class and the concrete +// providers that extend it. +import type { ThinkingProvider } from "./provider"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +export abstract class BaseThinkingProvider implements ThinkingProvider { + abstract readonly family: ThinkingFamily; + abstract readonly modelId: string; + + abstract schema(metadata?: ResolvedModelMetadata): { properties: Record } | undefined; + abstract buildPayload(thinking: ThinkingSettings, opts?: BuildThinkingPayloadOptions): Record; + abstract requestsThinking(thinking: ThinkingSettings): boolean; + + /** + * Map a per-model override (modelConfiguration / persisted fallback) onto the + * family settings. Returns the SAME reference when nothing changed so the + * resolver can detect whether an override actually applied. + */ + applyOverride(settings: ThinkingSettings, _override: Record): ThinkingSettings { + return settings; + } + + /** Model-level constraints applied after resolution (default: none). */ + normalize(settings: ThinkingSettings): ThinkingSettings { + return settings; + } + + /** + * Display decision: should `reasoning_content` be surfaced as visible text + * instead of a thinking part? Default false — reasoning is genuine CoT. + */ + treatReasoningAsContent(_url: string, _thinking: ThinkingSettings): boolean { + return false; + } + + /** Set a family field from `override.reasoningEffort` when valid and different. */ + protected applyEffort( + settings: ThinkingSettings, + override: Record, + field: keyof ThinkingSettings, + allowed: readonly string[], + ): ThinkingSettings { + const value = override.reasoningEffort; + if (typeof value === "string" && allowed.includes(value) && settings[field] !== value) { + return { ...settings, [field]: value }; + } + return settings; + } + + /** Set a family field from `override.thinkingMode` when valid and different. */ + protected applyMode( + settings: ThinkingSettings, + override: Record, + field: keyof ThinkingSettings, + allowed: readonly string[], + ): ThinkingSettings { + const value = override.thinkingMode; + if (typeof value === "string" && allowed.includes(value) && settings[field] !== value) { + return { ...settings, [field]: value }; + } + return settings; + } +} diff --git a/src/thinking/deepseek.ts b/src/thinking/deepseek.ts new file mode 100644 index 0000000..f62c782 --- /dev/null +++ b/src/thinking/deepseek.ts @@ -0,0 +1,53 @@ +/** + * DeepSeek thinking strategy. + * + * DeepSeek V4 is a native reasoning model: `reasoning_content` is always + * genuine chain-of-thought, so it must always go to the thinking panel — + * never be surfaced as visible text, regardless of the thinking effort. + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const DEEPSEEK_EFFORTS = ["off", "low", "medium", "high", "max"] as const; + +export class DeepSeekThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "deepseek"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: DEEPSEEK_EFFORTS, + labels: ["Off", "Low", "Medium", "High", "Max"], + descriptions: ["Fastest responses", "Minimal reasoning", "Balanced reasoning", "More reasoning", "Maximum reasoning"], + default: "off", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + return this.applyEffort(settings, override, "deepseek", DEEPSEEK_EFFORTS); + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + if (thinking.deepseek === "off") { + return {}; + } + return { reasoning_effort: thinking.deepseek }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.deepseek !== "off"; + } + + // treatReasoningAsContent: DeepSeek always emits genuine CoT → never content. +} diff --git a/src/thinking/fallback.ts b/src/thinking/fallback.ts new file mode 100644 index 0000000..5d55f9b --- /dev/null +++ b/src/thinking/fallback.ts @@ -0,0 +1,39 @@ +/** + * Fallback thinking strategy for models with no known family. + * + * Only reasoning-capable models (`metadata.reasoning`) get a generic off/on + * picker schema; no thinking fields are ever emitted to the request, and + * `reasoning_content` is always treated as genuine CoT (never visible text). + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, genericReasoningSchema, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +export class FallbackThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = null; + + constructor( + readonly modelId: string, + private readonly metadata?: ResolvedModelMetadata, + ) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + const effective = this.metadata ?? metadata; + return schemaFromReasoningOptions(effective) ?? (effective?.reasoning ? genericReasoningSchema() : undefined); + } + + // applyOverride: no known family → no override mapping (inherited default). + + buildPayload(_thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + return {}; + } + + requestsThinking(_thinking: ThinkingSettings): boolean { + return false; + } + + // treatReasoningAsContent: reasoning is genuine CoT → default false. +} diff --git a/src/thinking/glm.ts b/src/thinking/glm.ts new file mode 100644 index 0000000..adcca1d --- /dev/null +++ b/src/thinking/glm.ts @@ -0,0 +1,53 @@ +/** + * GLM (ZhipuAI) thinking strategy. + * + * Uses `thinking: { type: "enabled" | "disabled" }` when off, and + * `reasoning_effort` for concrete effort levels (high/max). The gateway does + * not transform GLM thinking params, so we send them through as-is. + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const GLM_EFFORTS = ["off", "high", "max"] as const; + +export class GlmThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "glm"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: GLM_EFFORTS, + labels: ["Off", "High", "Max"], + descriptions: ["Fastest responses", "Greater reasoning depth", "Maximum reasoning effort"], + default: "off", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + let next = this.applyEffort(settings, override, "glm", GLM_EFFORTS); + next = this.applyMode(next, override, "glm", GLM_EFFORTS); + return next; + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + if (thinking.glm === "off") { + return { thinking: { type: "disabled" } }; + } + return { reasoning_effort: thinking.glm }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.glm !== "off"; + } +} diff --git a/src/thinking/kimi.ts b/src/thinking/kimi.ts new file mode 100644 index 0000000..91b2a9e --- /dev/null +++ b/src/thinking/kimi.ts @@ -0,0 +1,81 @@ +/** + * Kimi thinking strategy. + * + * K2.7-code cannot disable thinking (Moonshot API constraint): the payload is + * always `{ thinking: { type: "enabled", keep: "all" } }` and the resolved + * setting is forced on via {@link normalize}. + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +// "off" first so the picker shows Off → On (matches the workspace default flow). +const KIMI_MODES = ["off", "on"] as const; + +export class KimiThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "kimi"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + const fromOptions = schemaFromReasoningOptions(metadata); + if (fromOptions) return fromOptions; + + // K2.7-code: thinking cannot be disabled — expose a single informational + // option so users understand the model always reasons. + if (/^kimi-k2\.7/i.test(this.modelId)) { + return { + properties: { + reasoningEffort: effortProperty({ + enum: ["on"], + labels: ["Always On (K2.7)"], + descriptions: ["Kimi K2.7-code requires thinking enabled (Moonshot API constraint)"], + default: "on", + }), + }, + }; + } + + return { + properties: { + reasoningEffort: effortProperty({ + enum: KIMI_MODES, + labels: ["Off", "On"], + descriptions: ["Fastest responses", "Enable thinking"], + default: "off", + }), + }, + }; + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + let next = this.applyEffort(settings, override, "kimi", KIMI_MODES); + next = this.applyMode(next, override, "kimi", KIMI_MODES); + return next; + } + + normalize(settings: ThinkingSettings): ThinkingSettings { + // K2.7-code forces thinking on regardless of picker selection (defensive — + // the picker schema only exposes "on", but VS Code may cache a stale value). + if (/^kimi-k2\.7/i.test(this.modelId) && settings.kimi !== "on") { + return { ...settings, kimi: "on" }; + } + return settings; + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + // K2.7-code: only type=enabled is allowed; keep:"all" preserves + // reasoning_content across multi-turn conversations (Moonshot spec). + if (/^kimi-k2\.7/i.test(this.modelId)) { + return { thinking: { type: "enabled", keep: "all" } }; + } + return { thinking: { type: thinking.kimi === "on" ? "enabled" : "disabled" } }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.kimi === "on"; + } +} diff --git a/src/thinking/mimo.ts b/src/thinking/mimo.ts new file mode 100644 index 0000000..a5636ab --- /dev/null +++ b/src/thinking/mimo.ts @@ -0,0 +1,72 @@ +/** + * Mimo (Xiaomi) thinking strategy. + * + * Uses `reasoning_effort` + a `budget_tokens` cap per effort level to prevent + * the infinite thinking loops observed in mimo-v2.5 / mimo-v2.5-pro (issue #36). + * + * DISPLAY: Mimo is a native reasoning model — `reasoning_content` is always + * genuine chain-of-thought and goes to the thinking panel, never surfaced as + * visible text. (The old #37635 gateway mislabel — wrapping answers in + * `reasoning_content` — is a gateway bug, not worked around here.) + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const MIMO_EFFORTS = ["off", "low", "medium", "high"] as const; + +/** Effort → reasoning-token budget cap (conservative; see buildPayload). */ +const MIMO_BUDGET_MAP: Record = { + low: 8192, + medium: 16384, + high: 32768, +}; + +export class MimoThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "mimo"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: MIMO_EFFORTS, + labels: ["Off", "Low", "Medium", "High"], + descriptions: ["Fastest responses", "Minimal reasoning", "Balanced reasoning", "Enable reasoning"], + default: "off", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + return this.applyEffort(settings, override, "mimo", MIMO_EFFORTS); + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + if (thinking.mimo === "off") { + return {}; + } + const mimoBudget = MIMO_BUDGET_MAP[thinking.mimo]; + return { + reasoning_effort: thinking.mimo, + ...(mimoBudget !== undefined ? { budget_tokens: mimoBudget } : {}), + }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.mimo !== "off"; + } + + // reasoning_content is always genuine CoT → never surfaced as visible text + // (the #37635 gateway mislabel is the gateway's bug, not worked around). + treatReasoningAsContent(_url: string, _thinking: ThinkingSettings): boolean { + return false; + } +} diff --git a/src/thinking/minimax.ts b/src/thinking/minimax.ts new file mode 100644 index 0000000..59f3217 --- /dev/null +++ b/src/thinking/minimax.ts @@ -0,0 +1,54 @@ +/** + * MiniMax thinking strategy. + * + * The OpenCode gateway only supports on/off for this family (`reasoning_effort` + * is silently ignored). m2.* models route through the messages endpoint with + * standard Anthropic enabled; m3 uses adaptive. + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const MINIMAX_MODES = ["off", "on"] as const; + +export class MiniMaxThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "minimax"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: MINIMAX_MODES, + labels: ["Off", "On"], + descriptions: ["Fastest responses", "Enable thinking"], + default: "off", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + return this.applyEffort(settings, override, "minimax", MINIMAX_MODES); + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + if (thinking.minimax === "off") { + return {}; + } + if (/^minimax-m2\./i.test(this.modelId)) { + return { thinking: { type: "enabled" } }; + } + return { thinking: { type: "adaptive" } }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.minimax === "on"; + } +} diff --git a/src/thinking/openai.ts b/src/thinking/openai.ts new file mode 100644 index 0000000..b573577 --- /dev/null +++ b/src/thinking/openai.ts @@ -0,0 +1,57 @@ +/** + * OpenAI GPT thinking strategy. + * + * GPT 5.x models route through the Responses API where reasoning is a nested + * `reasoning: { effort }` object. Supported values: none/minimal/low/medium/ + * high/xhigh/max (the gateway forwards `reasoning.effort` to the Responses API). + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const OPENAI_EFFORTS = ["off", "low", "medium", "high", "xhigh"] as const; + +export class OpenAiThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "openai"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: OPENAI_EFFORTS, + labels: ["Off", "Low", "Medium", "High", "XHigh"], + descriptions: [ + "Fastest responses", + "Faster responses with less reasoning", + "Balanced reasoning and speed", + "Greater reasoning depth", + "Maximum reasoning depth", + ], + default: "off", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + return this.applyEffort(settings, override, "openai", OPENAI_EFFORTS); + } + + buildPayload(thinking: ThinkingSettings, _opts?: BuildThinkingPayloadOptions): Record { + if (thinking.openai === "off") { + return {}; + } + return { reasoning: { effort: thinking.openai } }; + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.openai !== "off"; + } +} diff --git a/src/thinking/payload.ts b/src/thinking/payload.ts new file mode 100644 index 0000000..76a005c --- /dev/null +++ b/src/thinking/payload.ts @@ -0,0 +1,26 @@ +/** + * Payload-level thinking detection. + * + * CONTRACT: pure only — no `vscode` import, no side effects. + */ + +/** + * Whether a request body asks the model to think, through ANY channel the + * extension emits: `reasoning_effort`, `budget_tokens`, `enable_thinking`, + * or an Anthropic-style `thinking` block (`enabled` / `adaptive`). + * + * Used for diagnostics / logging. The DISPLAY decision (thinking part vs + * visible text) is made by each provider's `treatReasoningAsContent`, not by + * inferring intent from the body. + */ +export function bodyRequestsThinking(body: Record | undefined): boolean { + if (!body) return false; + if (typeof body.reasoning_effort === "string") return true; + if (typeof body.budget_tokens === "number") return true; + if (body.enable_thinking === true) return true; + if (body.thinking !== null && typeof body.thinking === "object") { + const type = (body.thinking as Record).type; + return type === "enabled" || type === "adaptive"; + } + return false; +} diff --git a/src/thinking/provider.ts b/src/thinking/provider.ts new file mode 100644 index 0000000..6a0ff92 --- /dev/null +++ b/src/thinking/provider.ts @@ -0,0 +1,77 @@ +/** + * Thinking provider registry. + * + * Each model family has its own strategy class (see `./deepseek.ts` etc.), + * selected by {@link thinkingProviderFor}. This module owns the interface and + * the single routing point from a raw model id to its strategy. + * + * CONTRACT: pure only — no `vscode` import, no side effects. + */ +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; +import { DeepSeekThinking } from "./deepseek"; +import { GlmThinking } from "./glm"; +import { KimiThinking } from "./kimi"; +import { MiniMaxThinking } from "./minimax"; +import { OpenAiThinking } from "./openai"; +import { QwenThinking } from "./qwen"; +import { MimoThinking } from "./mimo"; +import { FallbackThinking } from "./fallback"; + +/** Strategy interface implemented by each per-provider thinking class. */ +export interface ThinkingProvider { + /** Thinking family this strategy handles; `null` = fallback/unknown. */ + readonly family: ThinkingFamily; + /** Raw model id this strategy is bound to. */ + readonly modelId: string; + /** Picker schema properties (reasoningEffort / thinkingBudget). */ + schema(metadata?: ResolvedModelMetadata): { properties: Record } | undefined; + /** Map a per-model override onto the family settings (same ref if no change). */ + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings; + /** Build the request payload thinking fields (spread into the body). */ + buildPayload(thinking: ThinkingSettings, opts?: BuildThinkingPayloadOptions): Record; + /** Whether the resolved settings request thinking through any channel. */ + requestsThinking(thinking: ThinkingSettings): boolean; + /** Whether `reasoning_content` should be surfaced as visible text. */ + treatReasoningAsContent(url: string, thinking: ThinkingSettings): boolean; + /** Model-level constraints applied after resolution (e.g. k2.7 force-on). */ + normalize(settings: ThinkingSettings): ThinkingSettings; +} + +/** + * Detect which Thinking family a raw model id belongs to. Used both to render + * the per-model picker submenu (configurationSchema) and to map the user's + * per-request selection back to the right OpenCode request field. + */ +export function thinkingFamily(modelId: string): ThinkingFamily { + if (/^deepseek-/i.test(modelId)) return "deepseek"; + if (/^glm-/i.test(modelId)) return "glm"; + if (/^kimi-/i.test(modelId)) return "kimi"; + if (/^minimax-/i.test(modelId)) return "minimax"; + if (/^gpt-/i.test(modelId)) return "openai"; + if (/^qwen3(?:\.|-)/i.test(modelId)) return "qwen"; + if (/^mimo-/i.test(modelId)) return "mimo"; + return null; +} + +/** Resolve the thinking strategy for a raw model id. */ +export function thinkingProviderFor(modelId: string, metadata?: ResolvedModelMetadata): ThinkingProvider { + switch (thinkingFamily(modelId)) { + case "deepseek": + return new DeepSeekThinking(modelId); + case "glm": + return new GlmThinking(modelId); + case "kimi": + return new KimiThinking(modelId); + case "minimax": + return new MiniMaxThinking(modelId); + case "openai": + return new OpenAiThinking(modelId); + case "qwen": + return new QwenThinking(modelId); + case "mimo": + return new MimoThinking(modelId); + default: + return new FallbackThinking(modelId, metadata); + } +} diff --git a/src/thinking/qwen.ts b/src/thinking/qwen.ts new file mode 100644 index 0000000..0616215 --- /dev/null +++ b/src/thinking/qwen.ts @@ -0,0 +1,103 @@ +/** + * Qwen thinking strategy. + * + * Qwen routes through BOTH the chat-completions endpoint (`enable_thinking` / + * `thinking_budget`) and the Anthropic messages endpoint (native + * `thinking: { type, budget_tokens }`). The endpoint is chosen by the caller + * via {@link BuildThinkingPayloadOptions.endpoint}. + */ +import { BaseThinkingProvider } from "./base"; +import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; + +const QWEN_MODES = ["auto", "on", "off"] as const; +const QWEN_BUDGETS = ["auto", "4096", "16384", "32768", "81920"] as const; + +export class QwenThinking extends BaseThinkingProvider { + readonly family: ThinkingFamily = "qwen"; + + constructor(readonly modelId: string) { + super(); + } + + schema(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + return ( + schemaFromReasoningOptions(metadata) ?? { + properties: { + reasoningEffort: effortProperty({ + enum: QWEN_MODES, + labels: ["Off", "Auto", "On"], + descriptions: ["Fastest responses", "Model decides", "Enable thinking"], + default: "off", + }), + thinkingBudget: effortProperty({ + enum: QWEN_BUDGETS, + labels: ["Auto", "4K", "16K", "32K", "80K"], + descriptions: ["Provider default", "Small budget", "Medium budget", "Large budget", "Maximum budget"], + default: "auto", + title: "Thinking Budget", + group: "", + }), + }, + } + ); + } + + applyOverride(settings: ThinkingSettings, override: Record): ThinkingSettings { + let next = this.applyEffort(settings, override, "qwen", QWEN_MODES); + next = this.applyMode(next, override, "qwen", QWEN_MODES); + const budget = override.thinkingBudget; + if (typeof budget === "string" && (QWEN_BUDGETS as readonly string[]).includes(budget) && settings.qwenBudget !== budget) { + next = { ...next, qwenBudget: budget as ThinkingSettings["qwenBudget"] }; + } + return next; + } + + buildPayload(thinking: ThinkingSettings, opts?: BuildThinkingPayloadOptions): Record { + if (opts?.endpoint === "messages") { + return this.buildAnthropicPayload(thinking); + } + return this.buildChatPayload(thinking, opts?.hasImageInput ?? false); + } + + requestsThinking(thinking: ThinkingSettings): boolean { + return thinking.qwen === "on"; + } + + private buildChatPayload(thinking: ThinkingSettings, hasImageInput: boolean): Record { + if (thinking.qwen === "auto") { + // Let the model decide; don't send enable_thinking. Budget is only + // meaningful when thinking is active. Vision requests are already + // token-heavy; keep "auto" truly automatic. + if (hasImageInput) { + return {}; + } + return thinking.qwenBudget === "auto" ? {} : { thinking_budget: Number(thinking.qwenBudget) }; + } + if (thinking.qwen === "on") { + return thinking.qwenBudget === "auto" + ? { enable_thinking: true } + : { enable_thinking: true, thinking_budget: Number(thinking.qwenBudget) }; + } + return { enable_thinking: false }; + } + + /** Anthropic messages endpoint expects { type: "enabled"|"disabled", budget_tokens }. */ + private buildAnthropicPayload(thinking: ThinkingSettings): Record { + if (thinking.qwen === "on") { + const budget = thinking.qwenBudget === "auto" ? undefined : Number(thinking.qwenBudget); + return { + thinking: { + type: "enabled", + ...(budget !== undefined ? { budget_tokens: budget } : {}), + }, + }; + } + if (thinking.qwen === "off") { + return { thinking: { type: "disabled" } }; + } + // "auto" — let the provider decide; send no thinking directive. + return {}; + } +} diff --git a/src/thinking/resolve.ts b/src/thinking/resolve.ts new file mode 100644 index 0000000..0eaab77 --- /dev/null +++ b/src/thinking/resolve.ts @@ -0,0 +1,66 @@ +/** + * Thinking config resolution — the single place that merges all thinking-mode + * sources into one effective value, with explicit provenance. + * + * Priority (highest first): + * 1. `modelConfiguration` — live per-model config delivered by VS Code + * (picker submenu / Manage Language Models). This + * is the SINGLE authority for per-model thinking. + * 2. `workspace` — `opencodego.thinking.*` settings (default). + * 3. `default` — `THINKING_DEFAULTS` baked into the settings. + * + * CONTRACT: pure only — no `vscode` import, no side effects. The extension + * provides the raw sources; this module resolves them. + */ +import type { ResolvedModelMetadata } from "../metadata"; +import type { ThinkingSettings, ThinkingSource, ResolvedThinking, ThinkingOverride } from "./types"; +import { thinkingProviderFor } from "./provider"; + +export interface ResolveThinkingConfigInput { + modelId: string; + metadata?: ResolvedModelMetadata; + /** Workspace settings (`opencodego.thinking.*`), already defaulted. */ + workspace: ThinkingSettings; + /** Live per-model config from `options.modelConfiguration` (may be absent). */ + modelConfiguration?: Record; +} + +/** Resolve the effective thinking settings with provenance. */ +export function resolveThinkingConfig(input: ResolveThinkingConfigInput): ResolvedThinking { + const provider = thinkingProviderFor(input.modelId, input.metadata); + + let settings: ThinkingSettings = input.workspace; + let source: ThinkingSource = "workspace"; + let overrideApplied = false; + + // A delivered modelConfiguration always wins (even if its value equals the + // workspace baseline) — VS Code's per-model config is the single authority. + const liveOverride = extractThinkingOverride(input.modelConfiguration); + if (liveOverride) { + const next = provider.applyOverride(settings, input.modelConfiguration ?? {}); + overrideApplied = next !== settings; + settings = next; + source = "modelConfiguration"; + } + + // Apply model-level constraints (e.g. kimi-k2.7 force-on). + settings = provider.normalize(settings); + + return { settings, source, overrideApplied }; +} + +/** + * Extract the thinking-relevant keys from a `modelConfiguration` object. + * Returns undefined when none of the known keys carry a string value. + */ +export function extractThinkingOverride(modelConfiguration: Record | undefined): ThinkingOverride | undefined { + if (!modelConfiguration) return undefined; + const picked: ThinkingOverride = {}; + for (const key of ["reasoningEffort", "thinkingMode", "thinkingBudget"] as const) { + const value = modelConfiguration[key]; + if (typeof value === "string") { + picked[key] = value; + } + } + return Object.keys(picked).length ? picked : undefined; +} diff --git a/src/thinking/schema.ts b/src/thinking/schema.ts new file mode 100644 index 0000000..cc697f8 --- /dev/null +++ b/src/thinking/schema.ts @@ -0,0 +1,106 @@ +/** + * Shared picker-schema builders for the thinking system. + * + * CONTRACT: pure functions only — no `vscode` import, no side effects. + */ +import type { ResolvedModelMetadata } from "../metadata"; + +/** A plain JSON-schema-like object; the caller adds the VS Code annotation. */ +export interface ThinkingSchema { + properties: Record; +} + +/** Build a `reasoningEffort` schema property with the given enum options. */ +export function effortProperty(opts: { + enum: readonly string[]; + labels: readonly string[]; + descriptions: readonly string[]; + default?: string; + title?: string; + group?: string; +}): Record { + return { + type: "string", + title: opts.title ?? "Thinking Effort", + enum: [...opts.enum], + enumItemLabels: [...opts.labels], + enumDescriptions: [...opts.descriptions], + default: opts.default ?? "off", + group: opts.group ?? "navigation", + }; +} + +/** + * Priority 1 schema: derive options from models.dev `reasoning_options`. + * Returns undefined when no usable toggle/effort options exist, so callers + * fall back to their family-specific schema. + */ +export function schemaFromReasoningOptions(metadata?: ResolvedModelMetadata): ThinkingSchema | undefined { + const opts = metadata?.reasoningOptions; + if (!opts || opts.length === 0) return undefined; + + // Collect unique effort values across all effort-type options. + const effortValues = opts + .filter((o): o is { type: "effort"; values: string[] } => o.type === "effort" && Array.isArray(o.values) && o.values.length > 0) + .flatMap((o) => o.values) + .filter((v, i, a) => a.indexOf(v) === i); + + const hasToggle = opts.some((o) => o.type === "toggle"); + + if (!hasToggle && effortValues.length === 0) return undefined; + + const enumOptions: string[] = ["off"]; + const enumLabels: string[] = ["Off"]; + const enumDescriptions: string[] = ["Fastest responses"]; + + // Toggle-only (no effort values): add "on" for a simple off/on choice. + if (hasToggle && effortValues.length === 0) { + enumOptions.push("on"); + enumLabels.push("On"); + enumDescriptions.push("Enable reasoning"); + } + + for (const v of effortValues) { + enumOptions.push(v); + enumLabels.push(v.charAt(0).toUpperCase() + v.slice(1)); + switch (v) { + case "low": + enumDescriptions.push("Faster responses with less reasoning"); + break; + case "medium": + enumDescriptions.push("Balanced reasoning and speed"); + break; + case "high": + enumDescriptions.push("Greater reasoning depth but slower"); + break; + case "xhigh": + enumDescriptions.push("Maximum reasoning depth"); + break; + case "max": + enumDescriptions.push("Maximum reasoning effort"); + break; + default: + enumDescriptions.push(`Effort: ${v}`); + } + } + + return { + properties: { + reasoningEffort: effortProperty({ enum: enumOptions, labels: enumLabels, descriptions: enumDescriptions, default: "off" }), + }, + }; +} + +/** Generic off/on schema for any reasoning-capable model (Priority 3 fallback). */ +export function genericReasoningSchema(): ThinkingSchema { + return { + properties: { + reasoningEffort: effortProperty({ + enum: ["off", "on"], + labels: ["Off", "On"], + descriptions: ["Fastest responses", "Enable reasoning"], + default: "off", + }), + }, + }; +} diff --git a/src/thinking/types.ts b/src/thinking/types.ts new file mode 100644 index 0000000..304db35 --- /dev/null +++ b/src/thinking/types.ts @@ -0,0 +1,51 @@ +/** + * Thinking-system shared types. + * + * CONTRACT: pure types only — no `vscode` import, no side effects. + */ + +/** Per-family thinking settings stored in the workspace configuration. */ +export interface ThinkingSettings { + deepseek: "off" | "low" | "medium" | "high" | "max"; + glm: "off" | "high" | "max"; + kimi: "on" | "off"; + minimax: "off" | "on"; + openai: "off" | "low" | "medium" | "high" | "xhigh"; + qwen: "auto" | "on" | "off"; + qwenBudget: "auto" | "4096" | "16384" | "32768" | "81920"; + mimo: "off" | "low" | "medium" | "high"; +} + +/** Detected thinking family for a raw model id. `null` = no known family. */ +export type ThinkingFamily = "deepseek" | "glm" | "kimi" | "minimax" | "openai" | "qwen" | "mimo" | null; + +/** + * Which configuration layer supplied the effective thinking value. + * + * Priority (highest first): + * modelConfiguration → globalState → workspace → default + */ +export type ThinkingSource = "workspace" | "modelConfiguration" | "globalState" | "default"; + +/** Resolved thinking config with provenance. */ +export interface ResolvedThinking { + settings: ThinkingSettings; + source: ThinkingSource; + /** Whether the model's family value differs from the workspace baseline. */ + overrideApplied: boolean; +} + +/** The subset of `modelConfiguration` the thinking system understands. */ +export interface ThinkingOverride { + reasoningEffort?: string; + thinkingMode?: string; + thinkingBudget?: string; +} + +/** Options for building a request payload's thinking fields. */ +export interface BuildThinkingPayloadOptions { + /** Whether the request carries image input (affects Qwen auto behavior). */ + hasImageInput?: boolean; + /** Endpoint the request will hit — changes the payload shape (Qwen). */ + endpoint?: "chat" | "messages" | "responses"; +} From b8f6b4595343e7d5ec6c9ae21b9b37614c234532 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 12:14:22 +0800 Subject: [PATCH 02/22] refactor(request): split body builders into per-endpoint modules (openai/anthropic/google) --- src/request/anthropic.ts | 233 +++++++++++++++++++++++++++++++++++++++ src/request/builders.ts | 17 +++ src/request/google.ts | 176 +++++++++++++++++++++++++++++ src/request/openai.ts | 95 ++++++++++++++++ src/request/schema.ts | 94 ++++++++++++++++ src/request/shared.ts | 11 ++ src/request/types.ts | 117 ++++++++++++++++++++ 7 files changed, 743 insertions(+) create mode 100644 src/request/anthropic.ts create mode 100644 src/request/builders.ts create mode 100644 src/request/google.ts create mode 100644 src/request/openai.ts create mode 100644 src/request/schema.ts create mode 100644 src/request/shared.ts create mode 100644 src/request/types.ts diff --git a/src/request/anthropic.ts b/src/request/anthropic.ts new file mode 100644 index 0000000..a724bb7 --- /dev/null +++ b/src/request/anthropic.ts @@ -0,0 +1,233 @@ +/** + * Anthropic-family request body builder. + * + * Builds the Anthropic Messages API wire payload (Claude models, plus Qwen and + * MiniMax m2.* routed through the messages endpoint). + * + * CONTRACT: pure functions only — `vscode` is used as a TYPE and for the + * `LanguageModelChatToolMode` enum value only; no extension-host side effects. + */ +import * as vscode from "vscode"; +import { joinedTextContent } from "../responsesRequest"; +import { thinkingProviderFor } from "../thinking"; +import { sanitizeToolSchema } from "./schema"; +import { messagesHaveImages } from "./shared"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ModelLimits } from "../modelLimits"; +import type { + ApiMessage, + ApiSettings, + OpenAiContentPart, + AnthropicToolDefinition, + AnthropicRequestMessage, + AnthropicCacheControl, + AnthropicContentBlock, + AnthropicImageSource, +} from "./types"; + +export function buildAnthropicMessagesRequestBody( + modelId: string, + messages: ApiMessage[], + options: vscode.ProvideLanguageModelChatResponseOptions, + settings: ApiSettings, + metadata: ResolvedModelMetadata, + limits: ModelLimits, +): Record { + const tools = mapAnthropicTools(options.tools); + // The per-provider strategy picks the Anthropic-native thinking shape for + // Qwen on this endpoint ({ type: "enabled"|"disabled" }, budget_tokens). + const thinkingPayload = thinkingProviderFor(modelId).buildPayload(settings.thinking, { + hasImageInput: messagesHaveImages(messages), + endpoint: "messages", + }); + const anthropicMessages = buildAnthropicMessages(messages); + + return { + model: modelId, + // Only send temperature if the model supports it (not deprecated) + ...(metadata.temperature !== false ? { temperature: settings.temperature } : {}), + max_tokens: limits.maxOutputTokens, + stream: true, + messages: anthropicMessages, + ...thinkingPayload, + ...(tools.length ? { tools, tool_choice: anthropicToolChoice(options.toolMode) } : {}), + }; +} + +export function buildAnthropicMessages(messages: ApiMessage[]): AnthropicRequestMessage[] { + let cacheControlCount = 0; + const nextCacheControl = (): { cache_control?: AnthropicCacheControl } => { + cacheControlCount += 1; + return cacheControlCount <= 4 ? { cache_control: { type: "ephemeral" } } : {}; + }; + + const anthropicMessages: AnthropicRequestMessage[] = []; + + for (const message of messages) { + if (message.role === "user") { + const userBlocks = anthropicUserBlocks(message.content, nextCacheControl); + if (userBlocks.length) { + anthropicMessages.push({ role: "user", content: userBlocks }); + } + continue; + } + + if (message.role === "assistant") { + const assistantBlocks = anthropicAssistantBlocks(message, nextCacheControl); + if (assistantBlocks.length) { + anthropicMessages.push({ role: "assistant", content: assistantBlocks }); + } + continue; + } + + // After the user/assistant continues above, role is narrowed to "tool". + if (message.tool_call_id) { + anthropicMessages.push({ + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: message.tool_call_id, + content: anthropicToolResultContent(message.content, nextCacheControl), + ...nextCacheControl(), + }, + ], + }); + } + } + + if (!anthropicMessages.length) { + anthropicMessages.push({ + role: "user", + content: [{ type: "text", text: "Continue the conversation.", ...nextCacheControl() }], + }); + } + + return anthropicMessages; +} + +function anthropicUserBlocks( + content: ApiMessage["content"], + nextCacheControl: () => { cache_control?: AnthropicCacheControl }, +): AnthropicContentBlock[] { + if (typeof content === "string") { + return content.trim() ? [{ type: "text", text: content, ...nextCacheControl() }] : []; + } + + if (!Array.isArray(content)) { + return []; + } + + const blocks: AnthropicContentBlock[] = []; + for (const part of content) { + if (part.type === "text" && typeof part.text === "string" && part.text.length > 0) { + blocks.push({ type: "text", text: part.text, ...nextCacheControl() }); + continue; + } + + if (part.type === "image_url") { + const source = anthropicImageSource(part); + if (source) { + blocks.push({ type: "image", source, ...nextCacheControl() }); + } + } + } + + return blocks; +} + +// RULES: Anthropic tool_result.content accepts either a plain string or a +// list of content blocks. We use the string form when the message has no +// images (the common case, smaller payload), and fall back to the array form +// (text + image blocks) only when an image_url part is present. This keeps +// text-only tool results byte-for-byte identical to the previous behavior +// while enabling vision-capable Anthropic models to consume MCP screenshots. +function anthropicToolResultContent( + content: ApiMessage["content"], + nextCacheControl: () => { cache_control?: AnthropicCacheControl }, +): string | AnthropicContentBlock[] { + if (typeof content === "string") { + return content; + } + + if (!Array.isArray(content)) { + return ""; + } + + const hasImage = content.some((part) => part.type === "image_url" && part.image_url?.url); + if (!hasImage) { + return joinedTextContent(content, "\n"); + } + + return anthropicUserBlocks(content, nextCacheControl); +} + +function anthropicAssistantBlocks( + message: ApiMessage, + nextCacheControl: () => { cache_control?: AnthropicCacheControl }, +): AnthropicContentBlock[] { + const blocks: AnthropicContentBlock[] = []; + + const text = joinedTextContent(message.content); + if (text) { + blocks.push({ type: "text", text, ...nextCacheControl() }); + } + + for (const toolCall of message.tool_calls ?? []) { + blocks.push({ + type: "tool_use", + id: toolCall.id || `toolu_${Math.random().toString(36).slice(2)}`, + name: toolCall.function.name, + input: anthropicToolCallInput(toolCall.function.arguments), + ...nextCacheControl(), + }); + } + + return blocks; +} + +function anthropicToolCallInput(argumentsText: string): unknown { + if (!argumentsText.trim()) { + return {}; + } + + try { + return JSON.parse(argumentsText); + } catch { + return argumentsText; + } +} + +function anthropicImageSource(part: OpenAiContentPart): AnthropicImageSource | undefined { + if (part.type !== "image_url") { + return undefined; + } + + const url = part.image_url?.url; + if (typeof url !== "string" || !url) { + return undefined; + } + + const match = /^data:([^;]+);base64,(.*)$/i.exec(url); + if (match) { + return { + type: "base64", + media_type: match[1], + data: match[2], + }; + } + + return { type: "url", url }; +} + +function mapAnthropicTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): AnthropicToolDefinition[] { + return (tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + input_schema: sanitizeToolSchema(tool.inputSchema), + })); +} + +function anthropicToolChoice(mode: vscode.LanguageModelChatToolMode): { type: "auto" | "any" } { + return { type: mode === vscode.LanguageModelChatToolMode.Required ? "any" : "auto" }; +} diff --git a/src/request/builders.ts b/src/request/builders.ts new file mode 100644 index 0000000..ebcb077 --- /dev/null +++ b/src/request/builders.ts @@ -0,0 +1,17 @@ +/** + * Provider request-body builders — public barrel. + * + * Each OpenCode endpoint family owns its own module: + * - `./openai.ts` — chat-completions + Responses API + * - `./anthropic.ts` — Anthropic Messages API + * - `./google.ts` — Google generateContent + * Shared helpers live in `./schema.ts` (tool-schema sanitize) and + * `./shared.ts` (messagesHaveImages). This barrel keeps the legacy + * `import { ... } from "./request/builders"` call sites stable. + */ +export { buildChatCompletionsRequestBody, buildResponsesRequestBody } from "./openai"; +export { buildAnthropicMessagesRequestBody, buildAnthropicMessages } from "./anthropic"; +export { buildGoogleGenerateContentBody } from "./google"; +export { messagesHaveImages } from "./shared"; +export { sanitizeToolSchema } from "./schema"; +export type { ApiMessage, ApiSettings, AnthropicRequestMessage, AnthropicContentBlock } from "./types"; diff --git a/src/request/google.ts b/src/request/google.ts new file mode 100644 index 0000000..8448ec0 --- /dev/null +++ b/src/request/google.ts @@ -0,0 +1,176 @@ +/** + * Google (Gemini) request body builder. + * + * Builds the Google generateContent wire payload (Gemini models on the Zen + * gateway). + * + * CONTRACT: pure functions only — `vscode` is used as a TYPE and for the + * `LanguageModelChatToolMode` enum value only; no extension-host side effects. + */ +import * as vscode from "vscode"; +import { joinedTextContent } from "../responsesRequest"; +import { parseToolInput } from "../toolCallAccumulator"; +import { sanitizeToolSchema } from "./schema"; +import type { ModelLimits } from "../modelLimits"; +import type { ApiMessage, ApiSettings } from "./types"; + +export function buildGoogleGenerateContentBody( + messages: ApiMessage[], + options: vscode.ProvideLanguageModelChatResponseOptions, + settings: ApiSettings, + limits: ModelLimits, +): Record { + const tools = mapGoogleTools(options.tools); + + return { + contents: googleContentsFromMessages(messages), + generationConfig: { + maxOutputTokens: limits.maxOutputTokens, + temperature: settings.temperature, + }, + ...(tools.length ? { tools: [{ functionDeclarations: tools }], toolConfig: googleToolConfig(options.toolMode) } : {}), + }; +} + +function mapGoogleTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): Record[] { + return (tools ?? []).map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: sanitizeToolSchema(tool.inputSchema), + })); +} + +function googleToolConfig(mode: vscode.LanguageModelChatToolMode): Record { + return { + functionCallingConfig: { + mode: mode === vscode.LanguageModelChatToolMode.Required ? "ANY" : "AUTO", + }, + }; +} + +function googleContentsFromMessages(messages: ApiMessage[]): Record[] { + const toolNamesById = new Map(); + const contents: Record[] = []; + + for (const message of messages) { + if (message.role === "user") { + const parts = googleUserParts(message.content); + if (parts.length) { + contents.push({ role: "user", parts }); + } + continue; + } + + if (message.role === "assistant") { + const parts: Record[] = []; + if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) { + parts.push({ text: message.reasoning_content, thought: true }); + } + const text = joinedTextContent(message.content); + if (text) { + parts.push({ text }); + } + for (const toolCall of message.tool_calls ?? []) { + const args = parseToolInput(toolCall.function.arguments); + parts.push({ functionCall: { name: toolCall.function.name, args } }); + toolNamesById.set(toolCall.id, toolCall.function.name); + } + if (parts.length) { + contents.push({ role: "model", parts }); + } + continue; + } + + // After the user/model continues above, role is narrowed to "tool". + if (message.tool_call_id) { + const name = toolNamesById.get(message.tool_call_id) ?? "tool"; + const response = googleFunctionResponseContent(message.content, name); + contents.push({ + role: "user", + parts: [ + { + functionResponse: response, + }, + ], + }); + } + } + + return contents; +} + +function googleUserParts(content: ApiMessage["content"]): Record[] { + if (typeof content === "string") { + return content ? [{ text: content }] : []; + } + + if (!Array.isArray(content)) { + return []; + } + + return content.flatMap((part): Record[] => { + if (part.type === "text" && typeof part.text === "string") { + return [{ text: part.text }]; + } + + if (part.type === "image_url" && part.image_url?.url) { + const inlineData = dataUrlToInlineData(part.image_url.url); + return inlineData ? [{ inlineData }] : []; + } + + return []; + }); +} + +function dataUrlToInlineData(url: string): { mimeType: string; data: string } | undefined { + const match = /^data:(.+?);base64,(.+)$/i.exec(url); + if (!match) { + return undefined; + } + return { + mimeType: match[1], + data: match[2], + }; +} + +// RULES: Gemini's functionResponse.response is a flexible object. The plain +// form is `{ name, content }` where content is a JSON string (text-only tool +// results). When the tool result carries an image (e.g. MCP screenshot), we +// extend it with `parts` containing both the text and an inlineData block so +// vision-capable Gemini models can see the image. The `content` field is kept +// for backwards compatibility with providers that ignore the `parts` field. +function googleFunctionResponseContent( + content: ApiMessage["content"], + name: string, +): { name: string; content: string; parts?: Record[] } { + if (typeof content === "string") { + return { name, content }; + } + + if (!Array.isArray(content)) { + // ApiMessage content is `string | null | OpenAiContentPart[]`; after the + // string and array checks above, this branch only sees null. + return { name, content: JSON.stringify("") }; + } + + const text = joinedTextContent(content, "\n"); + const hasImage = content.some((part) => part.type === "image_url" && part.image_url?.url); + if (!hasImage) { + return { name, content: text }; + } + + const parts: Record[] = []; + if (text) { + parts.push({ text }); + } + for (const part of content) { + if (part.type === "image_url" && part.image_url?.url) { + const inlineData = dataUrlToInlineData(part.image_url.url); + if (inlineData) { + parts.push({ inlineData }); + } + } + } + + return { name, content: text, parts }; +} diff --git a/src/request/openai.ts b/src/request/openai.ts new file mode 100644 index 0000000..e36f6b9 --- /dev/null +++ b/src/request/openai.ts @@ -0,0 +1,95 @@ +/** + * OpenAI-family request body builders. + * + * Builds the wire payloads for the OpenAI-compatible chat-completions endpoint + * and the Responses API endpoint. + * + * CONTRACT: pure functions only — `vscode` is used as a TYPE and for the + * `LanguageModelChatToolMode` enum value only; no extension-host side effects. + */ +import * as vscode from "vscode"; +import { buildResponsesRequestEnvelope, responsesInputItemsFromMessage } from "../responsesRequest"; +import { thinkingProviderFor } from "../thinking"; +import { sanitizeToolSchema } from "./schema"; +import { messagesHaveImages } from "./shared"; +import type { ResolvedModelMetadata } from "../metadata"; +import type { ModelLimits } from "../modelLimits"; +import type { ApiMessage, ApiSettings, OpenAiToolDefinition } from "./types"; + +export function buildChatCompletionsRequestBody( + modelId: string, + messages: ApiMessage[], + options: vscode.ProvideLanguageModelChatResponseOptions, + settings: ApiSettings, + metadata: ResolvedModelMetadata, + limits: ModelLimits, +): Record { + const tools = mapOpenAiTools(options.tools); + const thinkingPayload = thinkingProviderFor(modelId).buildPayload(settings.thinking, { + hasImageInput: messagesHaveImages(messages), + endpoint: "chat", + }); + + return { + model: modelId, + messages, + // Only send temperature if the model supports it (not deprecated) + ...(metadata.temperature !== false ? { temperature: settings.temperature } : {}), + max_tokens: limits.maxOutputTokens, + stream: true, + stream_options: { include_usage: true }, + ...thinkingPayload, + ...(tools.length ? { tools, tool_choice: toolChoice(options.toolMode) } : {}), + }; +} + +export function buildResponsesRequestBody( + modelId: string, + messages: ApiMessage[], + options: vscode.ProvideLanguageModelChatResponseOptions, + settings: ApiSettings, + metadata: ResolvedModelMetadata, + limits: ModelLimits, +): Record { + const input = messages.flatMap((message) => responsesInputItemsFromMessage(message)); + const tools = mapResponsesTools(options.tools); + const thinkingPayload = thinkingProviderFor(modelId).buildPayload(settings.thinking, { + hasImageInput: messagesHaveImages(messages), + endpoint: "responses", + }); + + return buildResponsesRequestEnvelope({ + model: modelId, + input, + maxOutputTokens: limits.maxOutputTokens, + // Some models reject any non-default temperature value. + ...(metadata.temperature === false ? {} : { temperature: settings.temperature }), + thinkingPayload, + tools, + toolChoice: toolChoice(options.toolMode), + }); +} + +function mapOpenAiTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): OpenAiToolDefinition[] { + return (tools ?? []).map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: sanitizeToolSchema(tool.inputSchema), + }, + })); +} + +function mapResponsesTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): Record[] { + return (tools ?? []).map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: sanitizeToolSchema(tool.inputSchema), + })); +} + +function toolChoice(mode: vscode.LanguageModelChatToolMode): "auto" | "required" { + return mode === vscode.LanguageModelChatToolMode.Required ? "required" : "auto"; +} diff --git a/src/request/schema.ts b/src/request/schema.ts new file mode 100644 index 0000000..8006b0d --- /dev/null +++ b/src/request/schema.ts @@ -0,0 +1,94 @@ +/** + * Shared JSON-schema sanitization for tool definitions. + * + * Tools contributed by VS Code may carry `$ref`/`$defs`/`$id`/`$schema` keys + * and recursive references that some upstream OpenCode endpoints reject. This + * flattens a tool schema into a minimal, safe `{ type, properties, required }` + * shape shared by the OpenAI, Anthropic and Google request builders. + * + * CONTRACT: pure functions only — no `vscode` import, no side effects. + */ +import { isRecord } from "../utils"; + +export function sanitizeToolSchema(schema: unknown): object { + const root = isRecord(schema) ? schema : { type: "object", properties: {} }; + const sanitized = sanitizeJsonSchemaNode(root, root, new Set()); + if (!isRecord(sanitized)) { + return { type: "object", properties: {} }; + } + + return { + type: "object", + properties: isRecord(sanitized.properties) ? sanitized.properties : {}, + ...(Array.isArray(sanitized.required) ? { required: sanitized.required } : {}), + }; +} + +function sanitizeJsonSchemaNode(value: unknown, root: Record, seenRefs: Set): unknown { + if (Array.isArray(value)) { + return value.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs)); + } + + if (!isRecord(value)) { + return value; + } + + const ref = typeof value.$ref === "string" ? value.$ref : undefined; + if (ref?.startsWith("#/") && !seenRefs.has(ref)) { + const target = resolveJsonPointer(root, ref); + if (target !== undefined) { + const nextSeenRefs = new Set(seenRefs); + nextSeenRefs.add(ref); + const siblings = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$ref")); + const resolved = sanitizeJsonSchemaNode(target, root, nextSeenRefs); + return isRecord(resolved) + ? sanitizeJsonSchemaNode({ ...resolved, ...siblings }, root, nextSeenRefs) + : sanitizeJsonSchemaNode(siblings, root, nextSeenRefs); + } + } + + const result: Record = {}; + for (const [key, child] of Object.entries(value)) { + if (key === "$schema" || key === "$id" || key === "$ref" || key === "$defs" || key === "definitions") { + continue; + } + + if (key === "properties" && isRecord(child)) { + result.properties = Object.fromEntries( + Object.entries(child).map(([propertyName, propertySchema]) => [ + propertyName, + sanitizeJsonSchemaNode(propertySchema, root, seenRefs), + ]), + ); + continue; + } + + if (key === "items" || key === "additionalProperties") { + result[key] = sanitizeJsonSchemaNode(child, root, seenRefs); + continue; + } + + if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(child)) { + result[key] = child.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs)); + continue; + } + + if (["type", "description", "enum", "required", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"].includes(key)) { + result[key] = child; + } + } + + return result; +} + +function resolveJsonPointer(root: Record, pointer: string): unknown { + return pointer + .slice(2) + .split("/") + .reduce((current, segment) => { + if (!isRecord(current)) { + return undefined; + } + return current[segment.replace(/~1/g, "/").replace(/~0/g, "~")]; + }, root); +} diff --git a/src/request/shared.ts b/src/request/shared.ts new file mode 100644 index 0000000..b663fb6 --- /dev/null +++ b/src/request/shared.ts @@ -0,0 +1,11 @@ +/** + * Shared helpers for the request builders. + * + * CONTRACT: pure functions only — no `vscode` import, no side effects. + */ +import type { ApiMessage } from "./types"; + +/** Whether any message in the conversation carries an image part. */ +export function messagesHaveImages(messages: readonly ApiMessage[]): boolean { + return messages.some((message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url")); +} diff --git a/src/request/types.ts b/src/request/types.ts new file mode 100644 index 0000000..a1792c0 --- /dev/null +++ b/src/request/types.ts @@ -0,0 +1,117 @@ +/** + * Shared types for building provider request bodies. + * + * CONTRACT: pure types only — no `vscode` runtime import, no side effects. + * These are the wire shapes we construct for the OpenCode gateway. + */ +import type { ThinkingSettings } from "../thinking/types"; + +export type ApiRole = "system" | "user" | "assistant" | "tool"; + +/** Normalized internal message shape used by all request builders. */ +export interface ApiMessage { + role: ApiRole; + content: string | null | OpenAiContentPart[]; + reasoning_content?: string; + tool_call_id?: string; + tool_calls?: OpenAiToolCall[]; +} + +export interface OpenAiContentPart { + type: "text" | "image_url"; + text?: string; + image_url?: { + url: string; + }; +} + +export interface OpenAiToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +/** Resolved extension settings used to build request bodies. */ +export interface ApiSettings { + temperature: number; + maxOutputTokensOverride: number; + maxInputTokensOverride: number; + debugReasoning: boolean; + requestTimeoutMs: number; + streamIdleTimeoutMs: number; + thinking: ThinkingSettings; + stripThinkTags: "never" | "auto" | "always"; +} + +export interface OpenAiToolDefinition { + type: "function"; + function: { + name: string; + description: string; + parameters: object; + }; +} + +export interface AnthropicToolDefinition { + name: string; + description: string; + input_schema: object; +} + +export interface AnthropicCacheControl { + type: "ephemeral"; +} + +export interface AnthropicTextBlock { + type: "text"; + text: string; + cache_control?: AnthropicCacheControl; +} + +export interface AnthropicImageSourceUrl { + type: "url"; + url: string; +} + +export interface AnthropicImageSourceBase64 { + type: "base64"; + media_type: string; + data: string; +} + +export type AnthropicImageSource = AnthropicImageSourceUrl | AnthropicImageSourceBase64; + +export interface AnthropicImageBlock { + type: "image"; + source: AnthropicImageSource; + cache_control?: AnthropicCacheControl; +} + +export interface AnthropicToolUseBlock { + type: "tool_use"; + id: string; + name: string; + input: unknown; + cache_control?: AnthropicCacheControl; +} + +export interface AnthropicToolResultBlock { + type: "tool_result"; + tool_use_id: string; + // Anthropic tool_result.content may be either a plain string or a list of + // content blocks (text + image) per the Messages API spec. We support the + // array form so MCP tool results that include images (e.g. screenshots) are + // forwarded to vision-capable Anthropic models instead of being dropped. + content: string | AnthropicContentBlock[]; + cache_control?: AnthropicCacheControl; +} + +export type AnthropicContentBlock = AnthropicTextBlock | AnthropicImageBlock | AnthropicToolUseBlock | AnthropicToolResultBlock; + +export interface AnthropicRequestMessage { + role: "user" | "assistant"; + content: AnthropicContentBlock[]; +} From 287cd8bd1a56d70e0b9fc6b4170399e0b1015a88 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 12:25:30 +0800 Subject: [PATCH 03/22] refactor(extension): wire request builders + thinking provider into the response path --- src/extension.ts | 729 +++-------------------------------------------- src/streaming.ts | 56 ++-- 2 files changed, 75 insertions(+), 710 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index ccedda7..9ffc6c1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -19,14 +19,7 @@ import { type ResolvedModelMetadata, } from "./metadata"; import { resolveModelRouting } from "./routing"; -import { - buildFamilyThinkingSchema, - buildQwenAnthropicThinkingPayload, - buildThinkingPayload, - applyRequestThinkingOverride, - thinkingFamily, - type ThinkingSettings, -} from "./thinking"; +import { extractThinkingOverride, resolveThinkingConfig, thinkingFamily, thinkingProviderFor, type ThinkingSettings } from "./thinking"; import { shouldEchoThinkingHistory, thinkingTextFromValue } from "./reasoningHistory"; import { buildOpenCodeGatewayAuthHeaders } from "./openCodeAuth"; import { @@ -54,7 +47,14 @@ import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } import { providerModelDisplayName } from "./modelNames"; import { buildStableModelCapabilities } from "./modelCapabilities"; import { calculateModelLimits, type ModelLimits } from "./modelLimits"; -import { buildResponsesRequestEnvelope, joinedTextContent, responsesInputItemsFromMessage } from "./responsesRequest"; +import { + buildAnthropicMessagesRequestBody, + buildChatCompletionsRequestBody, + buildGoogleGenerateContentBody, + buildResponsesRequestBody, + messagesHaveImages, +} from "./request/builders"; +import type { ApiMessage, ApiSettings, OpenAiContentPart, OpenAiToolCall } from "./request/types"; import { runtimeDiagnosticsLines } from "./runtimeDiagnostics"; import { estimatePromptTokenCount, estimateTokenCount } from "./tokenEstimate"; import { @@ -143,7 +143,6 @@ import { sleep, toFiniteNumber, } from "./utils"; -import { parseToolInput as parseToolInputShared } from "./toolCallAccumulator"; import { isFreeModel } from "./metadata"; import { formatCacheHitRatio, formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage"; @@ -580,8 +579,6 @@ const PROVIDERS: Record = (() }; })(); -type ApiRole = "user" | "assistant" | "tool"; - interface OpenCodeModel extends vscode.LanguageModelChatInformation { endpointKind: ModelEndpointKind; provider: ProviderDefinition; @@ -617,31 +614,6 @@ interface ModelListResponse { data?: ModelListEntry[]; } -interface ApiMessage { - role: ApiRole; - content: string | null | OpenAiContentPart[]; - reasoning_content?: string; - tool_call_id?: string; - tool_calls?: OpenAiToolCall[]; -} - -interface OpenAiContentPart { - type: "text" | "image_url"; - text?: string; - image_url?: { - url: string; - }; -} - -interface OpenAiToolCall { - id: string; - type: "function"; - function: { - name: string; - arguments: string; - }; -} - interface ConvertedMessageResult { messages: ApiMessage[]; normalizedImageCount: number; @@ -658,17 +630,6 @@ interface ConvertedMessageResult { * chat-completions): the default is WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]. * DeepSeek V4 on openai-compatible additionally adds "max" → ["low", "medium", "high", "max"]. */ -interface ApiSettings { - temperature: number; - maxOutputTokensOverride: number; - maxInputTokensOverride: number; - debugReasoning: boolean; - requestTimeoutMs: number; - streamIdleTimeoutMs: number; - thinking: ThinkingSettings; - stripThinkTags: "never" | "auto" | "always"; -} - interface LanguageModelConfiguration { apiKey?: unknown; } @@ -722,76 +683,6 @@ type ConfiguredLanguageModelResponseOptions = vscode.ProvideLanguageModelChatRes let modelMetadataSnapshot: CachedModelMetadataSnapshot | undefined; let modelMetadataRefreshPromise: Promise | undefined; -interface OpenAiToolDefinition { - type: "function"; - function: { - name: string; - description: string; - parameters: object; - }; -} - -interface AnthropicToolDefinition { - name: string; - description: string; - input_schema: object; -} - -interface AnthropicCacheControl { - type: "ephemeral"; -} - -interface AnthropicTextBlock { - type: "text"; - text: string; - cache_control?: AnthropicCacheControl; -} - -interface AnthropicImageSourceUrl { - type: "url"; - url: string; -} - -interface AnthropicImageSourceBase64 { - type: "base64"; - media_type: string; - data: string; -} - -type AnthropicImageSource = AnthropicImageSourceUrl | AnthropicImageSourceBase64; - -interface AnthropicImageBlock { - type: "image"; - source: AnthropicImageSource; - cache_control?: AnthropicCacheControl; -} - -interface AnthropicToolUseBlock { - type: "tool_use"; - id: string; - name: string; - input: unknown; - cache_control?: AnthropicCacheControl; -} - -interface AnthropicToolResultBlock { - type: "tool_result"; - tool_use_id: string; - // Anthropic tool_result.content may be either a plain string or a list of - // content blocks (text + image) per the Messages API spec. We support the - // array form so MCP tool results that include images (e.g. screenshots) are - // forwarded to vision-capable Anthropic models instead of being dropped. - content: string | AnthropicContentBlock[]; - cache_control?: AnthropicCacheControl; -} - -type AnthropicContentBlock = AnthropicTextBlock | AnthropicImageBlock | AnthropicToolUseBlock | AnthropicToolResultBlock; - -interface AnthropicRequestMessage { - role: "user" | "assistant"; - content: AnthropicContentBlock[]; -} - interface RecentTransportSummary extends TransportRequestSummary { recordedAt: string; endpointKind: string; @@ -2916,16 +2807,17 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider` suffix made them go stale and + // reset — see issue #131). Multi-key resolution relies on the BYOK + // group's configuration.apiKey; apiKeysByModelId is only a fallback for + // the SecretStorage path. + const agentHostModelId = `${effectiveModelId}::agent-host`; const limits = modelLimits(metadata, settings); this.apiKeysByModelId.set(modelId, apiKey); - this.apiKeysByModelId.set(fpEffectiveModelId, apiKey); + this.apiKeysByModelId.set(effectiveModelId, apiKey); this.apiKeysByModelId.set(agentHostModelId, apiKey); const capacityNote = CAPACITY_LIMITED_MODEL_NOTES[modelId]; @@ -2976,7 +2868,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { @@ -3194,7 +3097,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { this.storeReasoningContent(toolCallIds, reasoningContent); }, @@ -3594,421 +3498,9 @@ async function refreshOpenCodeModelMetadata( return modelMetadataRefreshPromise; } -function buildChatCompletionsRequestBody( - modelId: string, - messages: ApiMessage[], - options: vscode.ProvideLanguageModelChatResponseOptions, - settings: ApiSettings, - metadata: ResolvedModelMetadata, - limits: ModelLimits, -): Record { - const tools = mapOpenAiTools(options.tools); - const thinkingPayload = buildThinkingPayload(modelId, settings.thinking, messagesHaveImages(messages)); - - return { - model: modelId, - messages, - // Only send temperature if the model supports it (not deprecated) - ...(metadata.temperature !== false ? { temperature: settings.temperature } : {}), - max_tokens: limits.maxOutputTokens, - stream: true, - stream_options: { include_usage: true }, - ...thinkingPayload, - ...(tools.length ? { tools, tool_choice: toolChoice(options.toolMode) } : {}), - }; -} - -function buildAnthropicMessagesRequestBody( - modelId: string, - messages: ApiMessage[], - options: vscode.ProvideLanguageModelChatResponseOptions, - settings: ApiSettings, - metadata: ResolvedModelMetadata, - limits: ModelLimits, -): Record { - const tools = mapAnthropicTools(options.tools); - const rawThinkingPayload = buildThinkingPayload(modelId, settings.thinking, messagesHaveImages(messages)); - // Qwen models routed to the Anthropic messages endpoint need thinking in - // Anthropic-native format ({ type: "enabled"|"disabled" }) rather than the - // Qwen-native enable_thinking boolean. If the payload contains - // enable_thinking, translate it; otherwise pass through as-is. - const thinkingPayload = - /^qwen3(?:\.|-)/i.test(modelId) && ("enable_thinking" in rawThinkingPayload || "thinking_budget" in rawThinkingPayload) - ? buildQwenAnthropicThinkingPayload(settings.thinking) - : rawThinkingPayload; - const anthropicMessages = buildAnthropicMessages(messages); - - return { - model: modelId, - // Only send temperature if the model supports it (not deprecated) - ...(metadata.temperature !== false ? { temperature: settings.temperature } : {}), - max_tokens: limits.maxOutputTokens, - stream: true, - messages: anthropicMessages, - ...thinkingPayload, - ...(tools.length ? { tools, tool_choice: anthropicToolChoice(options.toolMode) } : {}), - }; -} - -function buildAnthropicMessages(messages: ApiMessage[]): AnthropicRequestMessage[] { - let cacheControlCount = 0; - const nextCacheControl = (): { cache_control?: AnthropicCacheControl } => { - cacheControlCount += 1; - return cacheControlCount <= 4 ? { cache_control: { type: "ephemeral" } } : {}; - }; - - const anthropicMessages: AnthropicRequestMessage[] = []; - - for (const message of messages) { - if (message.role === "user") { - const userBlocks = anthropicUserBlocks(message.content, nextCacheControl); - if (userBlocks.length) { - anthropicMessages.push({ role: "user", content: userBlocks }); - } - continue; - } - - if (message.role === "assistant") { - const assistantBlocks = anthropicAssistantBlocks(message, nextCacheControl); - if (assistantBlocks.length) { - anthropicMessages.push({ role: "assistant", content: assistantBlocks }); - } - continue; - } - - // After the user/assistant continues above, role is narrowed to "tool". - if (message.tool_call_id) { - anthropicMessages.push({ - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: message.tool_call_id, - content: anthropicToolResultContent(message.content, nextCacheControl), - ...nextCacheControl(), - }, - ], - }); - } - } - - if (!anthropicMessages.length) { - anthropicMessages.push({ - role: "user", - content: [{ type: "text", text: "Continue the conversation.", ...nextCacheControl() }], - }); - } - - return anthropicMessages; -} - -function anthropicUserBlocks( - content: ApiMessage["content"], - nextCacheControl: () => { cache_control?: AnthropicCacheControl }, -): AnthropicContentBlock[] { - if (typeof content === "string") { - return content.trim() ? [{ type: "text", text: content, ...nextCacheControl() }] : []; - } - - if (!Array.isArray(content)) { - return []; - } - - const blocks: AnthropicContentBlock[] = []; - for (const part of content) { - if (part.type === "text" && typeof part.text === "string" && part.text.length > 0) { - blocks.push({ type: "text", text: part.text, ...nextCacheControl() }); - continue; - } - - if (part.type === "image_url") { - const source = anthropicImageSource(part); - if (source) { - blocks.push({ type: "image", source, ...nextCacheControl() }); - } - } - } - - return blocks; -} - -// RULES: Anthropic tool_result.content accepts either a plain string or a -// list of content blocks. We use the string form when the message has no -// images (the common case, smaller payload), and fall back to the array form -// (text + image blocks) only when an image_url part is present. This keeps -// text-only tool results byte-for-byte identical to the previous behavior -// while enabling vision-capable Anthropic models to consume MCP screenshots. -function anthropicToolResultContent( - content: ApiMessage["content"], - nextCacheControl: () => { cache_control?: AnthropicCacheControl }, -): string | AnthropicContentBlock[] { - if (typeof content === "string") { - return content; - } - - if (!Array.isArray(content)) { - return ""; - } - - const hasImage = content.some((part) => part.type === "image_url" && part.image_url?.url); - if (!hasImage) { - return joinedTextContent(content, "\n"); - } - - return anthropicUserBlocks(content, nextCacheControl); -} - -function anthropicAssistantBlocks( - message: ApiMessage, - nextCacheControl: () => { cache_control?: AnthropicCacheControl }, -): AnthropicContentBlock[] { - const blocks: AnthropicContentBlock[] = []; +// (chat/Anthropic/Responses/Google request builders migrated to src/request/builders.ts) - const text = joinedTextContent(message.content); - if (text) { - blocks.push({ type: "text", text, ...nextCacheControl() }); - } - - for (const toolCall of message.tool_calls ?? []) { - blocks.push({ - type: "tool_use", - id: toolCall.id || `toolu_${Math.random().toString(36).slice(2)}`, - name: toolCall.function.name, - input: anthropicToolCallInput(toolCall.function.arguments), - ...nextCacheControl(), - }); - } - - return blocks; -} - -function anthropicToolCallInput(argumentsText: string): unknown { - if (!argumentsText.trim()) { - return {}; - } - - try { - return JSON.parse(argumentsText); - } catch { - return argumentsText; - } -} - -function anthropicImageSource(part: OpenAiContentPart): AnthropicImageSource | undefined { - if (part.type !== "image_url") { - return undefined; - } - - const url = part.image_url?.url; - if (typeof url !== "string" || !url) { - return undefined; - } - - const match = /^data:([^;]+);base64,(.*)$/i.exec(url); - if (match) { - return { - type: "base64", - media_type: match[1], - data: match[2], - }; - } - - return { type: "url", url }; -} - -function mapResponsesTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): Record[] { - return (tools ?? []).map((tool) => ({ - type: "function", - name: tool.name, - description: tool.description, - parameters: sanitizeToolSchema(tool.inputSchema), - })); -} - -function buildResponsesRequestBody( - modelId: string, - messages: ApiMessage[], - options: vscode.ProvideLanguageModelChatResponseOptions, - settings: ApiSettings, - metadata: ResolvedModelMetadata, - limits: ModelLimits, -): Record { - const input = messages.flatMap((message) => responsesInputItemsFromMessage(message)); - const tools = mapResponsesTools(options.tools); - const thinkingPayload = buildThinkingPayload(modelId, settings.thinking, messagesHaveImages(messages)); - - return buildResponsesRequestEnvelope({ - model: modelId, - input, - maxOutputTokens: limits.maxOutputTokens, - // Some models reject any non-default temperature value. - ...(metadata.temperature === false ? {} : { temperature: settings.temperature }), - thinkingPayload, - tools, - toolChoice: toolChoice(options.toolMode), - }); -} - -function buildGoogleGenerateContentBody( - messages: ApiMessage[], - options: vscode.ProvideLanguageModelChatResponseOptions, - settings: ApiSettings, - limits: ModelLimits, -): Record { - const tools = mapGoogleTools(options.tools); - - return { - contents: googleContentsFromMessages(messages), - generationConfig: { - maxOutputTokens: limits.maxOutputTokens, - temperature: settings.temperature, - }, - ...(tools.length ? { tools: [{ functionDeclarations: tools }], toolConfig: googleToolConfig(options.toolMode) } : {}), - }; -} - -function mapGoogleTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): Record[] { - return (tools ?? []).map((tool) => ({ - name: tool.name, - description: tool.description, - parameters: sanitizeToolSchema(tool.inputSchema), - })); -} - -function googleToolConfig(mode: vscode.LanguageModelChatToolMode): Record { - return { - functionCallingConfig: { - mode: mode === vscode.LanguageModelChatToolMode.Required ? "ANY" : "AUTO", - }, - }; -} - -function googleContentsFromMessages(messages: ApiMessage[]): Record[] { - const toolNamesById = new Map(); - const contents: Record[] = []; - - for (const message of messages) { - if (message.role === "user") { - const parts = googleUserParts(message.content); - if (parts.length) { - contents.push({ role: "user", parts }); - } - continue; - } - - if (message.role === "assistant") { - const parts: Record[] = []; - if (typeof message.reasoning_content === "string" && message.reasoning_content.trim()) { - parts.push({ text: message.reasoning_content, thought: true }); - } - const text = joinedTextContent(message.content); - if (text) { - parts.push({ text }); - } - for (const toolCall of message.tool_calls ?? []) { - const args = parseToolInputShared(toolCall.function.arguments); - parts.push({ functionCall: { name: toolCall.function.name, args } }); - toolNamesById.set(toolCall.id, toolCall.function.name); - } - if (parts.length) { - contents.push({ role: "model", parts }); - } - continue; - } - - // After the user/model continues above, role is narrowed to "tool". - if (message.tool_call_id) { - const name = toolNamesById.get(message.tool_call_id) ?? "tool"; - const response = googleFunctionResponseContent(message.content, name); - contents.push({ - role: "user", - parts: [ - { - functionResponse: response, - }, - ], - }); - } - } - - return contents; -} - -function googleUserParts(content: ApiMessage["content"]): Record[] { - if (typeof content === "string") { - return content ? [{ text: content }] : []; - } - - if (!Array.isArray(content)) { - return []; - } - - return content.flatMap((part): Record[] => { - if (part.type === "text" && typeof part.text === "string") { - return [{ text: part.text }]; - } - - if (part.type === "image_url" && part.image_url?.url) { - const inlineData = dataUrlToInlineData(part.image_url.url); - return inlineData ? [{ inlineData }] : []; - } - - return []; - }); -} - -function dataUrlToInlineData(url: string): { mimeType: string; data: string } | undefined { - const match = /^data:(.+?);base64,(.+)$/i.exec(url); - if (!match) { - return undefined; - } - return { - mimeType: match[1], - data: match[2], - }; -} - -// RULES: Gemini's functionResponse.response is a flexible object. The plain -// form is `{ name, content }` where content is a JSON string (text-only tool -// results). When the tool result carries an image (e.g. MCP screenshot), we -// extend it with `parts` containing both the text and an inlineData block so -// vision-capable Gemini models can see the image. The `content` field is kept -// for backwards compatibility with providers that ignore the `parts` field. -function googleFunctionResponseContent( - content: ApiMessage["content"], - name: string, -): { name: string; content: string; parts?: Record[] } { - if (typeof content === "string") { - return { name, content }; - } - - if (!Array.isArray(content)) { - // ApiMessage content is `string | null | OpenAiContentPart[]`; after the - // string and array checks above, this branch only sees null. - return { name, content: JSON.stringify("") }; - } - - const text = joinedTextContent(content, "\n"); - const hasImage = content.some((part) => part.type === "image_url" && part.image_url?.url); - if (!hasImage) { - return { name, content: text }; - } - - const parts: Record[] = []; - if (text) { - parts.push({ text }); - } - for (const part of content) { - if (part.type === "image_url" && part.image_url?.url) { - const inlineData = dataUrlToInlineData(part.image_url.url); - if (inlineData) { - parts.push({ inlineData }); - } - } - } - - return { name, content: text, parts }; -} +// (google request builders migrated to src/request/builders.ts) // The official OpenCode client sends these headers on every request. The Zen // gateway reads x-opencode-session first, then converts that sticky identifier @@ -4115,115 +3607,7 @@ function stableHash(value: string): string { return (hash >>> 0).toString(16).padStart(8, "0"); } -function mapOpenAiTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): OpenAiToolDefinition[] { - return (tools ?? []).map((tool) => ({ - type: "function", - function: { - name: tool.name, - description: tool.description, - parameters: sanitizeToolSchema(tool.inputSchema), - }, - })); -} - -function mapAnthropicTools(tools: readonly vscode.LanguageModelChatTool[] | undefined): AnthropicToolDefinition[] { - return (tools ?? []).map((tool) => ({ - name: tool.name, - description: tool.description, - input_schema: sanitizeToolSchema(tool.inputSchema), - })); -} - -function sanitizeToolSchema(schema: unknown): object { - const root = isRecord(schema) ? schema : { type: "object", properties: {} }; - const sanitized = sanitizeJsonSchemaNode(root, root, new Set()); - if (!isRecord(sanitized)) { - return { type: "object", properties: {} }; - } - - return { - type: "object", - properties: isRecord(sanitized.properties) ? sanitized.properties : {}, - ...(Array.isArray(sanitized.required) ? { required: sanitized.required } : {}), - }; -} - -function sanitizeJsonSchemaNode(value: unknown, root: Record, seenRefs: Set): unknown { - if (Array.isArray(value)) { - return value.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs)); - } - - if (!isRecord(value)) { - return value; - } - - const ref = typeof value.$ref === "string" ? value.$ref : undefined; - if (ref?.startsWith("#/") && !seenRefs.has(ref)) { - const target = resolveJsonPointer(root, ref); - if (target !== undefined) { - const nextSeenRefs = new Set(seenRefs); - nextSeenRefs.add(ref); - const siblings = Object.fromEntries(Object.entries(value).filter(([key]) => key !== "$ref")); - const resolved = sanitizeJsonSchemaNode(target, root, nextSeenRefs); - return isRecord(resolved) - ? sanitizeJsonSchemaNode({ ...resolved, ...siblings }, root, nextSeenRefs) - : sanitizeJsonSchemaNode(siblings, root, nextSeenRefs); - } - } - - const result: Record = {}; - for (const [key, child] of Object.entries(value)) { - if (key === "$schema" || key === "$id" || key === "$ref" || key === "$defs" || key === "definitions") { - continue; - } - - if (key === "properties" && isRecord(child)) { - result.properties = Object.fromEntries( - Object.entries(child).map(([propertyName, propertySchema]) => [ - propertyName, - sanitizeJsonSchemaNode(propertySchema, root, seenRefs), - ]), - ); - continue; - } - - if (key === "items" || key === "additionalProperties") { - result[key] = sanitizeJsonSchemaNode(child, root, seenRefs); - continue; - } - - if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(child)) { - result[key] = child.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs)); - continue; - } - - if (["type", "description", "enum", "required", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"].includes(key)) { - result[key] = child; - } - } - - return result; -} - -function resolveJsonPointer(root: Record, pointer: string): unknown { - return pointer - .slice(2) - .split("/") - .reduce((current, segment) => { - if (!isRecord(current)) { - return undefined; - } - return current[segment.replace(/~1/g, "/").replace(/~0/g, "~")]; - }, root); -} - -function toolChoice(mode: vscode.LanguageModelChatToolMode): "auto" | "required" { - return mode === vscode.LanguageModelChatToolMode.Required ? "required" : "auto"; -} - -function anthropicToolChoice(mode: vscode.LanguageModelChatToolMode): { type: "auto" | "any" } { - return { type: mode === vscode.LanguageModelChatToolMode.Required ? "any" : "auto" }; -} +// (tool mapping + JSON-schema sanitize migrated to src/request/builders.ts) async function convertMessage( message: vscode.LanguageModelChatRequestMessage, @@ -4626,9 +4010,7 @@ function normalizeMessages(messages: ApiMessage[]): ApiMessage[] { return normalized.length ? normalized : [{ role: "user", content: "" }]; } -function messagesHaveImages(messages: readonly ApiMessage[]): boolean { - return messages.some((message) => Array.isArray(message.content) && message.content.some((part) => part.type === "image_url")); -} +// messagesHaveImages migrated to src/request/builders.ts /** * Replace image content parts in older messages with a placeholder text note @@ -4741,10 +4123,9 @@ function modelConfigurationSchema(modelId: string, metadata?: ResolvedModelMetad const properties: Record = {}; // --- Thinking / Reasoning Effort --- - // Priority 1: if models.dev provides explicit reasoning_options, use those. - // Priority 2: fall back to family-based hardcoded values. - // Priority 3: dynamic fallback for any model with reasoning: true. - const builtinSchema = buildFamilyThinkingSchema(modelId, metadata); + // Delegated to the per-provider strategy (schemaFromReasoningOptions first, + // then family hardcoded, then generic reasoning fallback). + const builtinSchema = thinkingProviderFor(modelId, metadata).schema(metadata); if (builtinSchema) { Object.assign(properties, builtinSchema.properties); @@ -4773,13 +4154,9 @@ function modelConfigurationSchema(modelId: string, metadata?: ResolvedModelMetad /** * Build the thinking-effort portion of the configuration schema. - * Delegated to `./thinking.ts` (pure, testable). + * Delegated to the per-provider strategy in `./thinking` (pure, testable). */ -// All thinking helpers (buildFamilyThinkingSchema, applyRequestThinkingOverride, -// buildThinkingPayload, buildQwenAnthropicThinkingPayload, thinkingFamily) are -// imported from ./thinking.ts at the top of this file. - function getRequestModelConfiguration(options: vscode.ProvideLanguageModelChatResponseOptions): Record | undefined { // The field is `modelConfiguration` in the current proposed API; older // builds shipped it under `configuration` alongside the auth config. Accept @@ -4792,18 +4169,6 @@ function getRequestModelConfiguration(options: vscode.ProvideLanguageModelChatRe return opts.modelConfiguration ?? opts.configuration; } -function pickThinkingModelConfiguration(override: Record | undefined): Record | undefined { - if (!override) return undefined; - const picked: Record = {}; - for (const key of ["reasoningEffort", "thinkingMode", "thinkingBudget"]) { - const value = override[key]; - if (typeof value === "string") { - picked[key] = value; - } - } - return Object.keys(picked).length ? picked : undefined; -} - function getSettings(): ApiSettings { const config = vscode.workspace.getConfiguration(CONFIG_SECTION); @@ -4837,8 +4202,8 @@ function getSettings(): ApiSettings { }; } -// buildThinkingPayload and buildQwenAnthropicThinkingPayload are imported from -// ./thinking.ts (pure, testable). +// The thinking provider strategies (thinkingProviderFor, resolveThinkingConfig) +// are imported from ./thinking (pure, testable). function modelLimits( metadata: ResolvedModelMetadata, diff --git a/src/streaming.ts b/src/streaming.ts index c9e80f2..68151d4 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -50,6 +50,16 @@ export interface StreamRequestOptions { onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void; capacityLimitedModelNotes?: Record; onTransportSummary?: (summary: TransportRequestSummary) => void; + /** + * Whether `reasoning_content` should be surfaced as visible text instead of + * a thinking part. Computed UPSTREAM by the thinking provider strategy from + * the resolved thinking config — never inferred from the body here. + * + * Currently false for every family: reasoning models emit genuine CoT in + * `reasoning_content`, so it always goes to the thinking panel. (The old + * gateway #37635 mislabel is the gateway's bug, not worked around here.) + */ + treatReasoningAsContent?: boolean; /** * Controls whether `...` tags inlined in the model's text * content are stripped and accumulated as reasoning content. @@ -89,20 +99,14 @@ export interface TransportRequestSummary { 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. + // Display decision: whether `reasoning_content` should be surfaced as visible + // text instead of a thinking part. Computed UPSTREAM by the thinking provider + // strategy from the resolved thinking config — not inferred from the body + // here. Currently false for all providers: reasoning_content is genuine CoT. const isGoGateway = options.url.includes("/zen/go/"); const body = options.body as Record | undefined; - // "Thinking is ON" whenever the body asks for reasoning through any channel - // (reasoning_effort, budget_tokens, enable_thinking, thinking block — Kimi - // K2.7 and MiniMax M3 route through chat-completions with Anthropic-style - // shapes), see bodyRequestsThinking(). const hasReasoningEffort = isGoGateway && bodyRequestsThinking(body); - const treatReasoningAsContent = isGoGateway && !hasReasoningEffort; + const treatReasoningAsContent = options.treatReasoningAsContent ?? false; if (isGoGateway) { options.output?.appendLine( `[go-gw] model=${options.modelId} hasReasoningEffort=${String(hasReasoningEffort)} treatReasoningAsContent=${String(treatReasoningAsContent)}`, @@ -126,8 +130,9 @@ export async function streamChatCompletions(options: StreamRequestOptions): Prom extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); - // Thinking-off responses surfaced the reasoning as visible text (gateway - // bug #37635); attach the marker so the next turn echoes reasoning_content. + // Dormant marker path: no provider treats reasoning as visible text anymore + // (old gateway #37635 mislabel is not worked around), so flushReasoningMarker + // is a no-op today — kept as the designed seam. const reasoningMarker = extractor.flushReasoningMarker(); if (reasoningMarker) { options.progress.report(reasoningMarker); @@ -980,18 +985,13 @@ class OpenAiResponseExtractor extends BaseResponseExtractor { localRequestId?: string, 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. + * Display seam: whether `reasoning_content` should be emitted as a visible + * `LanguageModelTextPart` instead of 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. + * Computed upstream by the thinking provider strategy. Currently always + * false — reasoning models emit genuine CoT in `reasoning_content`, so it + * goes to the thinking panel. (The old gateway #37635 mislabel is not + * worked around.) */ private readonly treatReasoningAsContent = false, ) { @@ -1107,10 +1107,10 @@ class OpenAiResponseExtractor extends BaseResponseExtractor { } 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. Suffix-repetition loop guard still applies. + // Dormant display seam: were treatReasoningAsContent true AND + // delta.content empty, reasoning_content would be emitted as visible + // text (old gateway #37635 mislabel). Never set today — reasoning is + // genuine CoT and goes to the thinking panel. Loop guard still applies. if (this.treatReasoningAsContent && !visible && text.length === 0) { if (!this.shouldSuppressThinkingEmit(reasoning)) { this.emittedTextLength += reasoning.length; From 32030e10c47f502db2480769ed50cebd00612ab8 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 12:26:06 +0800 Subject: [PATCH 04/22] fix(scripts): run npm bin shims through the shell on Windows --- scripts/staged-lint.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/staged-lint.ts b/scripts/staged-lint.ts index 757c3d1..770146e 100644 --- a/scripts/staged-lint.ts +++ b/scripts/staged-lint.ts @@ -23,7 +23,12 @@ import pc from "picocolors"; const root = path.resolve(import.meta.dirname, ".."); -const bin = (name: string): string => path.join(root, "node_modules", ".bin", name); +const bin = (name: string): string => { + const base = path.join(root, "node_modules", ".bin", name); + // On Windows the npm shims are `.cmd` files and must be spawned through the + // shell; spawning the extension-less shim yields ENOENT. + return process.platform === "win32" ? `${base}.cmd` : base; +}; const SRC_DIRS = ["src", "scripts"]; const TS_EXT = new Set([".ts", ".tsx", ".js", ".cjs", ".cts"]); @@ -35,7 +40,12 @@ interface CommandResult { } function run(cmd: string, args: string[]): CommandResult { - const res: SpawnSyncReturns = spawnSync(cmd, args, { cwd: root, encoding: "utf8" }); + const res: SpawnSyncReturns = spawnSync(cmd, args, { + cwd: root, + encoding: "utf8", + // Windows cannot exec `.cmd` shims directly; route through the shell. + shell: process.platform === "win32", + }); return { status: res.status, output: `${res.stdout}${res.stderr}`.trim() }; } From 97b6412e9e74a54655b8ec2b323900e80cc620ac Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 12:26:18 +0800 Subject: [PATCH 05/22] fix(usage): match cwd path segments with both separators (Windows) --- src/goUsageTracker.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index d66df4c..a866a06 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -172,23 +172,33 @@ export function normalizeCwd(value: string): string { return normalized; } +/** Whether `value` starts with `prefix` followed by a path separator. */ +function startsWithPathSegment(value: string, prefix: string): boolean { + if (!value.startsWith(prefix)) { + return false; + } + return value.length > prefix.length && (value.charAt(prefix.length) === "/" || value.charAt(prefix.length) === "\\"); +} + /** * Whether a CLI row's working directory belongs to the current workspace. * Matches when the folder equals the cwd, is a parent of it (the user opened * the repo root but the CLI ran in a subfolder), or the folder is a subfolder * of the cwd (the user opened a subfolder of the project). + * + * Segment-boundary matching accepts both `/` and `\` so POSIX-style paths and + * native Windows paths (where the separator is `\`) both match on any host. */ export function isCwdInWorkspace(cwd: string | undefined, workspaceFolders: readonly string[]): boolean { if (!cwd || workspaceFolders.length === 0) { return false; } const rowCwd = normalizeCwd(cwd); - const sep = process.platform === "win32" ? "\\" : "/"; for (const folder of workspaceFolders) { const normalized = normalizeCwd(folder); if (rowCwd === normalized) return true; - if (rowCwd.startsWith(`${normalized}${sep}`)) return true; - if (normalized.startsWith(`${rowCwd}${sep}`)) return true; + if (startsWithPathSegment(rowCwd, normalized)) return true; + if (startsWithPathSegment(normalized, rowCwd)) return true; } return false; } From dd21bbc638c71ac9782e3c3253bb5e9cd1686094 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 13:00:02 +0800 Subject: [PATCH 06/22] fix(scripts): make npm run lint pass on Windows lint.ts spawned the extension-less node_modules/.bin shims, which ENOENTs on Windows; route them through the shell and use the .cmd variant, matching the staged-lint fix. Add .gitattributes enforcing LF normalization so files are checked out with LF even with core.autocrlf=true, keeping prettier and shellcheck green on Windows. --- .gitattributes | 10 ++++++++++ scripts/lint.ts | 14 ++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ad2b9fa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize every text file to LF in the index and on checkout. This keeps +# `npm run lint` (prettier + shellcheck, which require LF per .editorconfig) +# green on Windows, where core.autocrlf=true would otherwise check files out +# as CRLF. Binary files are untouched (text=auto detects them). +* text=auto eol=lf + +# Shell scripts must stay LF: husky hooks (checked by shellcheck via +# `npm run lint`) and any POSIX scripts break with CRLF on Windows checkouts. +.husky/* text eol=lf +*.sh text eol=lf diff --git a/scripts/lint.ts b/scripts/lint.ts index 10ffe80..4ec1f7e 100644 --- a/scripts/lint.ts +++ b/scripts/lint.ts @@ -9,7 +9,12 @@ import pc from "picocolors"; const root = path.resolve(import.meta.dirname, ".."); -const bin = (name: string): string => path.join(root, "node_modules", ".bin", name); +const bin = (name: string): string => { + const base = path.join(root, "node_modules", ".bin", name); + // On Windows the npm shims are `.cmd` files and must be spawned through the + // shell; spawning the extension-less shim yields ENOENT. + return process.platform === "win32" ? `${base}.cmd` : base; +}; // Strip markdownlint-cli2 banner/summary noise and prettier's status header. const NOISE = /^(markdownlint-cli2 v|Finding:|Linting:|Summary:|Checking formatting\.\.\.)/; @@ -45,7 +50,12 @@ const steps: LintStep[] = [ console.log(pc.bold("Lint")); let failed = false; for (const step of steps) { - const res = spawnSync(step.cmd, step.args, { cwd: root, encoding: "utf8" }); + const res = spawnSync(step.cmd, step.args, { + cwd: root, + encoding: "utf8", + // Windows cannot exec `.cmd` shims directly; route through the shell. + shell: process.platform === "win32", + }); const output = clean(`${res.stdout}${res.stderr}`); if (res.status === 0) { console.log(` ${pc.green("✔")} ${step.label}`); From 30cf2f8ba5c2a5c4e14b10c0793a841d3fb0a5b5 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 13:04:09 +0800 Subject: [PATCH 07/22] docs: changelog + devlog for thinking refactor and request split Add an [Unreleased] changelog entry and update the devlog (session entry, Session Handoff, Completed History) for the per-provider thinking strategies, single-config-authority resolution, request module split and Windows lint fixes. --- CHANGELOG.md | 10 +++++ docs/devlog.md | 107 +++++++++++++++++++++++++++++-------------------- 2 files changed, 74 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a051bd..9f3a066 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documented here. +## [Unreleased] + +### Changed + +- **`[Internal]` Per-provider Thinking strategy classes + single config authority.** The thinking/reasoning system is refactored from one monolithic builder into a per-provider strategy (`src/thinking/`): an interface + factory (`provider.ts`), a shared base class, and one class per model family (`deepseek`, `glm`, `kimi`, `minimax`, `openai`, `qwen`, `mimo`, `fallback`). Each provider now owns its reasoning picker schema, its request-payload mapping, and whether its `reasoning_content` is surfaced as chat content. Configuration resolves from a **single authority** — the VS Code per-model configuration (model picker / Manage), with workspace settings and per-family defaults as fallbacks — instead of competing sources (workspace + modelConfiguration + a `globalState` shadow copy + defaults). The shadow copy is removed, so a thinking effort chosen for one model can no longer silently leak onto another model or override an explicit "Off". Model IDs are normalized to `effectiveModelId` (the `::sk-***` fp suffix is gone), which also stops the per-model settings group from being recreated on every pick. Request builders are split out of `extension.ts` into per-endpoint modules (`src/request/{types,schema,shared,openai,anthropic,google}.ts`). Windows tooling fixes: `scripts/lint.ts` runs npm `.cmd` shims through the shell and a new `.gitattributes` enforces LF normalization, so `npm run lint` (prettier + shellcheck) is green on Windows; `scripts/staged-lint.ts` and `isCwdInWorkspace` get the same treatment. + +### Fixed + +- **DeepSeek / Mimo thinking content no longer leaks into the chat transcript.** `treatReasoningAsContent` was mis-detecting native-reasoning families as "no reasoning in body" and echoing their `reasoning_content` as plain chat text. The decision now comes from the provider strategy (always `false` for DeepSeek and Mimo), so chain-of-thought stays in the thinking panel. + ## [0.6.0] — 2026-08-13 ### Added diff --git a/docs/devlog.md b/docs/devlog.md index 6650062..b347b1e 100644 --- a/docs/devlog.md +++ b/docs/devlog.md @@ -1,6 +1,26 @@ # 🧠 OPENCODE COPILOT CHAT DEVLOG -**Branch:** `main` | **Updated:** 2026-08-13 Asia/Jakarta | **Current Phase:** Autocomplete (#49) + central-config/usage refactor (#138) merged; PR #133/#135/#136/#138 all landed. `main` HEAD `616d6f6`. +**Branch:** `refactor/thinking-request-modules` | **Updated:** 2026-08-13 Asia/Jakarta | **Current Phase:** thinking refactor + request module split (6 commits, PR pending); prior: Autocomplete (#49) + central-config/usage refactor (#138) merged on `main` HEAD `616d6f6`. + +--- + +## ✅ Thinking refactor + per-request modules + Windows lint fixes — 2026-08-13 + +**Branch:** `refactor/thinking-request-modules` (6 commits ahead of `main`, PR drafted) + +**Action:** Refactored the thinking/reasoning system and split the monolithic `extension.ts` request path, per user request to align with VS Code extension standards. + +**What:** + +1. **Per-provider Thinking strategies** (`src/thinking/`, commit `400861c`). One strategy class per model family (`deepseek` / `glm` / `kimi` / `minimax` / `openai` / `qwen` / `mimo` / `fallback`) behind a shared interface + factory. Each owns its picker schema, request-payload mapping, and `treatReasoningAsContent`. Pure modules (no `vscode` import) keep unit-testability in plain Node. +2. **Single config authority** (`resolve.ts`). VS Code per-model configuration wins (as VS Code itself designs), then workspace settings, then per-family defaults. Removed the `globalState` shadow copy of thinking overrides — root cause of (a) "Max" being treated as "Off" (fp-suffixed model IDs never matched the per-model config group) and (b) "Off" being silently overridden by a shadow "max". Model IDs normalized to `effectiveModelId` (no `::sk-***` fp suffix), which also stops the per-model settings group from being recreated on every pick (related to #131 / PR #135). +3. **CoT leak fix.** `treatReasoningAsContent` now comes from the provider strategy — always `false` for DeepSeek and Mimo — so native-reasoning models keep chain-of-thought in the thinking panel instead of echoing it into the chat transcript. Upstream gateway bug (#37635) deliberately not worked around for Mimo (user decision). +4. **Request module split** (commits `ad1df51` + `e63b757`). Body builders + message/tool conversions moved out of `extension.ts` (~640 lines) into `src/request/{types,schema,shared,openai,anthropic,google}.ts`. +5. **Windows fixes** (commits `679a851`, `21e8393`, `ec5b27f`). `staged-lint.ts` and `lint.ts` run npm `.cmd` shims through the shell (fixes ENOENT on Windows); `isCwdInWorkspace` matches both path separators; new `.gitattributes` enforces LF normalization so prettier + shellcheck pass on Windows. + +**Verification:** `npm run compile` clean; `npm test` **290/290**; `npm run lint` fully green on Windows (was silently ENOENT-failing before); `npm run package` produced `opencode-copilot-chat-0.6.0.vsix`. + +**Docs:** CHANGELOG `[Unreleased]` updated. Live testing with a real API key not done; `npm run test-retry` (mock server) is available. --- @@ -451,13 +471,13 @@ ## ⚡ Session Handoff -| Field | Value | -| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **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. (12) #98 premature tool-call flush regression — fix implemented, pending compile/test. | +| Field | Value | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Last Session** | 2026-08-13 (thinking refactor + request split, branch `refactor/thinking-request-modules`) | +| **Worked On** | Diagnosed the "Max thinking treated as Off" bug: four competing config sources (workspace settings, VS Code per-model config, a `globalState` shadow copy, defaults) plus fp-suffixed model IDs meant the per-model config never reached the request path, and `treatReasoningAsContent` mis-echoed CoT for native-reasoning families. Refactored per user direction into per-provider Thinking strategy classes (`src/thinking/`), made VS Code per-model config the single authority, removed the shadow state and `fpEffectiveModelId`, and made the CoT-surfacing decision per provider (DeepSeek/Mimo always `false`). Split the request path out of `extension.ts` into `src/request/` (openai/anthropic/google builders). Fixed `npm run lint` on Windows: `lint.ts`/`staged-lint.ts` shell-shim fix + `.gitattributes` LF normalization (prettier/shellcheck were failing on CRLF checkouts). | +| **Stopped At** | 6 commits on the branch, all green: compile ✅, 290/290 tests ✅, full `npm run lint` ✅, VSIX packages ✅. CHANGELOG `[Unreleased]` + devlog updated. PR body drafted, branch not yet pushed. | +| **Next Action** | → Push branch → open PR (template applied) → optionally run `npm run test-retry` live mock E2E. Remaining architecture candidates (user-approved direction): `src/request/headers.ts`, `OpenCodeProvider` class → `src/providers/`, usage webview HTML → own file, `commands.ts`, metadata cache module. | +| **Open Issues** | (1) #131 duplicate-model group / PR #135 — our `effectiveModelId` normalization is related; confirm interaction. (2) Upstream #37635 gateway CoT bug still open (Mimo intentionally not worked around). (3) #98/#36 remain closed/verified. (4) Live API validation (`npm run validate-models`) not run — requires `OPENCODE_API_KEY`. (5) VSIX currently includes local `AGENTS.md` + `.codegraph/` (not in `.vscodeignore`); minor, follow-up. | --- @@ -1850,41 +1870,42 @@ rg -n "sk-[A-Za-z0-9]|apiKey.*[A-Za-z0-9]{20,}|Authorization: Bearer [A-Za-z0-9] ## 📋 Completed History -| Date | Version | Summary | -| ---------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 2026-06-13 | docs | Deep audit — all 4 🟢 Active docs verified against codebase + git history + CHANGELOG. All marked ✅ Solved: issue #19 (PR #15 merged), references #01 (research complete), architecture #01 (living ref complete), issue #01 (all code fixed v0.1.9/v0.1.10, remaining tool-call loop is model behavior not code bug). 0 Active docs remain. | -| 2026-06-13 | docs | Rewrote devlog into work-context format and flagged unrelated `WORK-CONTEXT.md` content | -| 2026-06-13 | docs | Backdated and consolidated the 2026-05-15 Qwen 3.6 Plus Free tool-call loop investigation | -| 2026-06-12 | docs | Added provider architecture reference for Go/Zen BYOK setup and later routing/metadata/usage evolution | -| 2026-06-12 | v0.2.7 | Temperature support fix, Kimi thinking format correction | -| 2026-06-11 | research | Agents window model visibility — GitHub Issue #11 deep-dive. Investigated VS Code source (`targetChatSessionType`, `chatSessions`, `chatSessionsProvider` proposed API). Concluded Option A (duplicate models with `targetChatSessionType: 'copilotcli'`) is only marketplace-compatible path. Custom "OpenCode" tab blocked by `vsce` proposed API policy. Doc: `docs/references/01-20260611-agents-window-model-visibility.md` | -| 2026-06-10 | v0.2.6 | Removed message trimming + gzip | -| 2026-06-10 | v0.2.5 | Removed gzip HTTP 500 path | -| 2026-06-10 | v0.2.4 | Context size selector, dynamic reasoning, thinking controls, strip think tags | -| 2026-06-09 | cleanup | Project cleanup — full codebase review (20+ improvements across 5 categories), fixed 4 immediate bugs: redundant activationEvents, stale user-agent version, duplicate CHANGELOG, .vsix gitignore. Doc: `docs/issues/18-20260609-project-cleanup-immediate-bugfixes.md` | -| 2026-06-09 | v0.2.3 | Output channel cleanup — removed all verbose debug/informational logs from "OpenCode" channel, fixed `Buffer` TS error with `TextDecoder` Web API, refreshed extension icon (commit `c8383735`), version bumped to 0.2.3. Doc: `docs/issues/16-20260609-output-channel-cleanup-textdecoder-fix.md` | -| 2026-06-08 | v0.2.2 | Strip think tags from model output | -| 2026-06-08 | icon | Extension icon redesign — replaced generic `` bracket logo with creative OpenCode Mark design (gradients, glow, grid pattern, sparkle accents). Researched brand assets from `anomalyco/opencode` source. Doc: `docs/features/04-20260608-extension-icon-redesign.md` | -| 2026-06-06 | v0.2.1 | Removed unused Go usage panel/command | -| 2026-06-05 | usage-debug | Go Usage Tracker status bar not updating — REST API exhaustive search (all 404), CLI dependency removal, session.percent bug fix, debug output channel, temporary v0.2.1 test VSIX. Doc: `docs/issues/14-20260605-go-usage-status-bar-not-updating.md` | -| 2026-06-05 | v0.2.0 | Go Usage Tracker feature implementation — GitHub user request → OpenCode pricing research → status bar + Quick Pick design → `goUsageTracker.ts` + `extension.ts` → VSIX build. Doc: `docs/features/03-20260605-go-usage-tracker.md` | -| 2026-06-05 | v0.1.10 | Qwen routing reverted to Anthropic Messages API; Anthropic SSE tool call parsing; Qwen thinking payload | -| 2026-06-04 | v0.1.8 | PR #7 review/merge/release — languageModelPricing API, models.dev cost data, 4-tier priceCategory, modality detection, type consolidation, experimental config cleanup. Doc: `docs/issues/11-20260604-pr7-pricing-api-review-merge-release.md` | -| 2026-06-04 | v0.1.9 | Qwen tool calling fixed (routed to chat-completions); context window for Qwen | -| 2026-06-04 | v0.1.8 | languageModelPricing, modality detection, cost metadata, capabilities alignment | -| 2026-05-27 | v0.1.7 | Transport diagnostics + Context Window usage integration — added native `usage` DataPart reporting, kept OpenCode custom usage telemetry, restored richer token counting, integrated PR #6, packaged and installed `0.1.7`, and merged `develop` back to `main`. Doc: `docs/issues/10-20260527-context-window-usage-pr6-integration.md` | -| 2026-05-24 | v0.1.6 | PR #4 review/merge/release — native Zen routing, models.dev cache, modular split, 5 unit tests, vision fixes preserved, marketplace VSIX packaged. Doc: `docs/issues/09-20260524-pr4-review-merge-release.md` | -| 2026-05-21 | v0.1.6 | models.dev metadata cache, Zen GPT/Gemini routing, timeouts | -| 2026-05-20 | v0.1.5 | Vision image request fixes and release consolidation — replaced stack-overflow-prone image byte encoding, diagnosed provider-side Alibaba `429 insufficient_quota`, omitted Qwen `thinking_budget` for image requests when Thinking is `auto`, audited OpenCode attachment metadata, removed incorrect `Vision` capability from GLM/MiniMax/MiMo Pro rows, restored the `0.1.5` changelog entry, compiled final output, and merged `develop` into `main` with `--no-ff`. Doc: `docs/issues/08-20260520-vision-image-request-fixes.md` | -| 2026-05-17 | zen-labels | Zen model version label fix — preserved decimal version labels such as `Claude Opus 4.6`, diagnosed stale installed VSIX artifacts, rebuilt the final `0.1.4` package, and moved `reasoningEffort` changelog wording under Added. Doc: `docs/issues/07-20260517-zen-model-version-labels.md` | -| 2026-05-17 | thinking-native-submenu | Native Thinking submenu solved — confirmed VS Code configuration pipeline, found diagnostics command was warming provider metadata, added automatic provider metadata warm-up, shortened Copilot-style labels, fixed Kimi/Moonshot tool schema sanitizer, fixed Qwen chat-completions routing and hybrid stream parsing, rebuilt final `0.1.4` VSIX. Doc: `docs/issues/06-20260517-thinking-native-submenu-investigation.md` | -| 2026-05-17 | thinking | Per-model Thinking controls — documented the feature covering family defaults, `configurationSchema`, `reasoningEffort`, `modelConfiguration`, `models.dev` reasoning options, request payload mapping, and command/settings fallback. Doc: `docs/features/02-20260517-per-model-thinking-controls.md` | -| 2026-05-17 | PR #1 | First community contribution merged — `opencodego.freeOnly` setting by @Wallacy. Reviewed, tested locally, merged via GitHub UI, synced `develop`. Doc: `docs/issues/04-20260517-pr1-freeonly-review-merge.md` | -| 2026-05-17 | v0.1.4 | Zen free filtering, thinking controls, schema sanitization, unavailable filtering | -| 2026-05-16 | v0.1.3 follow-up | Unavailable/deprecated model filtering — hid Ring and Trinity stale IDs, applied `models.dev` deprecated status filtering, and synced model docs | -| 2026-05-16 | v0.1.3 | Context-size correction and per-provider model limits | -| 2026-05-15 | investigation | Qwen 3.6 Plus Free tool-call infinite loop — root cause identified. Doc: `docs/issues/01-20260515-qwen36-tool-call-loop.md` | -| 2026-05-14 | v0.1.0–0.1.2 | Initial Go provider, native BYOK, separate Zen provider | +| Date | Version | Summary | +| ---------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 2026-08-13 | refactor/thinking-request-modules | Thinking refactor (per-provider strategy classes + single VS Code per-model config authority + removed globalState shadow + `effectiveModelId`) + request module split (`src/request/`) + Windows lint fixes (`.cmd` shims + `.gitattributes` LF). 6 commits. CHANGELOG [Unreleased] updated. | +| 2026-06-13 | docs | Deep audit — all 4 🟢 Active docs verified against codebase + git history + CHANGELOG. All marked ✅ Solved: issue #19 (PR #15 merged), references #01 (research complete), architecture #01 (living ref complete), issue #01 (all code fixed v0.1.9/v0.1.10, remaining tool-call loop is model behavior not code bug). 0 Active docs remain. | +| 2026-06-13 | docs | Rewrote devlog into work-context format and flagged unrelated `WORK-CONTEXT.md` content | +| 2026-06-13 | docs | Backdated and consolidated the 2026-05-15 Qwen 3.6 Plus Free tool-call loop investigation | +| 2026-06-12 | docs | Added provider architecture reference for Go/Zen BYOK setup and later routing/metadata/usage evolution | +| 2026-06-12 | v0.2.7 | Temperature support fix, Kimi thinking format correction | +| 2026-06-11 | research | Agents window model visibility — GitHub Issue #11 deep-dive. Investigated VS Code source (`targetChatSessionType`, `chatSessions`, `chatSessionsProvider` proposed API). Concluded Option A (duplicate models with `targetChatSessionType: 'copilotcli'`) is only marketplace-compatible path. Custom "OpenCode" tab blocked by `vsce` proposed API policy. Doc: `docs/references/01-20260611-agents-window-model-visibility.md` | +| 2026-06-10 | v0.2.6 | Removed message trimming + gzip | +| 2026-06-10 | v0.2.5 | Removed gzip HTTP 500 path | +| 2026-06-10 | v0.2.4 | Context size selector, dynamic reasoning, thinking controls, strip think tags | +| 2026-06-09 | cleanup | Project cleanup — full codebase review (20+ improvements across 5 categories), fixed 4 immediate bugs: redundant activationEvents, stale user-agent version, duplicate CHANGELOG, .vsix gitignore. Doc: `docs/issues/18-20260609-project-cleanup-immediate-bugfixes.md` | +| 2026-06-09 | v0.2.3 | Output channel cleanup — removed all verbose debug/informational logs from "OpenCode" channel, fixed `Buffer` TS error with `TextDecoder` Web API, refreshed extension icon (commit `c8383735`), version bumped to 0.2.3. Doc: `docs/issues/16-20260609-output-channel-cleanup-textdecoder-fix.md` | +| 2026-06-08 | v0.2.2 | Strip think tags from model output | +| 2026-06-08 | icon | Extension icon redesign — replaced generic `` bracket logo with creative OpenCode Mark design (gradients, glow, grid pattern, sparkle accents). Researched brand assets from `anomalyco/opencode` source. Doc: `docs/features/04-20260608-extension-icon-redesign.md` | +| 2026-06-06 | v0.2.1 | Removed unused Go usage panel/command | +| 2026-06-05 | usage-debug | Go Usage Tracker status bar not updating — REST API exhaustive search (all 404), CLI dependency removal, session.percent bug fix, debug output channel, temporary v0.2.1 test VSIX. Doc: `docs/issues/14-20260605-go-usage-status-bar-not-updating.md` | +| 2026-06-05 | v0.2.0 | Go Usage Tracker feature implementation — GitHub user request → OpenCode pricing research → status bar + Quick Pick design → `goUsageTracker.ts` + `extension.ts` → VSIX build. Doc: `docs/features/03-20260605-go-usage-tracker.md` | +| 2026-06-05 | v0.1.10 | Qwen routing reverted to Anthropic Messages API; Anthropic SSE tool call parsing; Qwen thinking payload | +| 2026-06-04 | v0.1.8 | PR #7 review/merge/release — languageModelPricing API, models.dev cost data, 4-tier priceCategory, modality detection, type consolidation, experimental config cleanup. Doc: `docs/issues/11-20260604-pr7-pricing-api-review-merge-release.md` | +| 2026-06-04 | v0.1.9 | Qwen tool calling fixed (routed to chat-completions); context window for Qwen | +| 2026-06-04 | v0.1.8 | languageModelPricing, modality detection, cost metadata, capabilities alignment | +| 2026-05-27 | v0.1.7 | Transport diagnostics + Context Window usage integration — added native `usage` DataPart reporting, kept OpenCode custom usage telemetry, restored richer token counting, integrated PR #6, packaged and installed `0.1.7`, and merged `develop` back to `main`. Doc: `docs/issues/10-20260527-context-window-usage-pr6-integration.md` | +| 2026-05-24 | v0.1.6 | PR #4 review/merge/release — native Zen routing, models.dev cache, modular split, 5 unit tests, vision fixes preserved, marketplace VSIX packaged. Doc: `docs/issues/09-20260524-pr4-review-merge-release.md` | +| 2026-05-21 | v0.1.6 | models.dev metadata cache, Zen GPT/Gemini routing, timeouts | +| 2026-05-20 | v0.1.5 | Vision image request fixes and release consolidation — replaced stack-overflow-prone image byte encoding, diagnosed provider-side Alibaba `429 insufficient_quota`, omitted Qwen `thinking_budget` for image requests when Thinking is `auto`, audited OpenCode attachment metadata, removed incorrect `Vision` capability from GLM/MiniMax/MiMo Pro rows, restored the `0.1.5` changelog entry, compiled final output, and merged `develop` into `main` with `--no-ff`. Doc: `docs/issues/08-20260520-vision-image-request-fixes.md` | +| 2026-05-17 | zen-labels | Zen model version label fix — preserved decimal version labels such as `Claude Opus 4.6`, diagnosed stale installed VSIX artifacts, rebuilt the final `0.1.4` package, and moved `reasoningEffort` changelog wording under Added. Doc: `docs/issues/07-20260517-zen-model-version-labels.md` | +| 2026-05-17 | thinking-native-submenu | Native Thinking submenu solved — confirmed VS Code configuration pipeline, found diagnostics command was warming provider metadata, added automatic provider metadata warm-up, shortened Copilot-style labels, fixed Kimi/Moonshot tool schema sanitizer, fixed Qwen chat-completions routing and hybrid stream parsing, rebuilt final `0.1.4` VSIX. Doc: `docs/issues/06-20260517-thinking-native-submenu-investigation.md` | +| 2026-05-17 | thinking | Per-model Thinking controls — documented the feature covering family defaults, `configurationSchema`, `reasoningEffort`, `modelConfiguration`, `models.dev` reasoning options, request payload mapping, and command/settings fallback. Doc: `docs/features/02-20260517-per-model-thinking-controls.md` | +| 2026-05-17 | PR #1 | First community contribution merged — `opencodego.freeOnly` setting by @Wallacy. Reviewed, tested locally, merged via GitHub UI, synced `develop`. Doc: `docs/issues/04-20260517-pr1-freeonly-review-merge.md` | +| 2026-05-17 | v0.1.4 | Zen free filtering, thinking controls, schema sanitization, unavailable filtering | +| 2026-05-16 | v0.1.3 follow-up | Unavailable/deprecated model filtering — hid Ring and Trinity stale IDs, applied `models.dev` deprecated status filtering, and synced model docs | +| 2026-05-16 | v0.1.3 | Context-size correction and per-provider model limits | +| 2026-05-15 | investigation | Qwen 3.6 Plus Free tool-call infinite loop — root cause identified. Doc: `docs/issues/01-20260515-qwen36-tool-call-loop.md` | +| 2026-05-14 | v0.1.0–0.1.2 | Initial Go provider, native BYOK, separate Zen provider | --- From 379d86ac7649de59c6190ecf9c7d2253a6199a20 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 13:18:27 +0800 Subject: [PATCH 08/22] refactor(api-key): drop command-palette key entry, BYOK panel only Remove the OpenCode Go/Zen Set API Key commands and the Set/Clear API Key items in Manage Provider; keys are now configured exclusively through VS Codes native BYOK flow (Language Models -> + Add Models). SecretStorage stays as an internal per-vendor mirror (opencodego.apiKey / opencodezen.apiKey) that the BYOK resolution writes so agent-host variants and cold-start requests inherit the group key, fixing the latent collision where Go and Zen shared a single secret and overwrote each others key. Refresh Models / Test Connection now point at the BYOK flow when no key is configured. --- CHANGELOG.md | 2 + README.md | 11 ++- ...0260514-open-code-provider-architecture.md | 11 ++- package.json | 4 - src/config.ts | 9 ++- src/extension.ts | 81 +++++-------------- src/test/config.test.ts | 10 +++ 7 files changed, 51 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3a066..62fe265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Changed +- **`[Internal]` API keys are configured through the BYOK panel only.** The `OpenCode Go: Set API Key` / `OpenCode Zen: Set API Key` commands and the "Set / Clear API Key" menu items inside `Manage Provider` are removed — keys are entered once via **Chat: Manage Language Models → "+ Add Models"** (the native BYOK flow). `SecretStorage` is no longer a user-facing entry point; it stays as an internal per-vendor mirror (`opencodego.apiKey` / `opencodezen.apiKey`) that the BYOK resolution writes so agent-host variants and cold-start requests inherit the group key. Splitting the secret per vendor also fixes a latent collision where Go and Zen shared a single `opencodego.apiKey` and overwrote each other's key. `Refresh Models` / `Test Connection` now point at the BYOK flow when no key is configured instead of prompting for one. + - **`[Internal]` Per-provider Thinking strategy classes + single config authority.** The thinking/reasoning system is refactored from one monolithic builder into a per-provider strategy (`src/thinking/`): an interface + factory (`provider.ts`), a shared base class, and one class per model family (`deepseek`, `glm`, `kimi`, `minimax`, `openai`, `qwen`, `mimo`, `fallback`). Each provider now owns its reasoning picker schema, its request-payload mapping, and whether its `reasoning_content` is surfaced as chat content. Configuration resolves from a **single authority** — the VS Code per-model configuration (model picker / Manage), with workspace settings and per-family defaults as fallbacks — instead of competing sources (workspace + modelConfiguration + a `globalState` shadow copy + defaults). The shadow copy is removed, so a thinking effort chosen for one model can no longer silently leak onto another model or override an explicit "Off". Model IDs are normalized to `effectiveModelId` (the `::sk-***` fp suffix is gone), which also stops the per-model settings group from being recreated on every pick. Request builders are split out of `extension.ts` into per-endpoint modules (`src/request/{types,schema,shared,openai,anthropic,google}.ts`). Windows tooling fixes: `scripts/lint.ts` runs npm `.cmd` shims through the shell and a new `.gitattributes` enforces LF normalization, so `npm run lint` (prettier + shellcheck) is green on Windows; `scripts/staged-lint.ts` and `isCwdInWorkspace` get the same treatment. ### Fixed diff --git a/README.md b/README.md index 486b8a3..acae08a 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ | 🎯 **Smart routing** | Each model family auto-routes to its native transport (`/responses`, `/messages`, `streamGenerateContent`, `/chat/completions`) | | 🖼️ **Vision + PDF + Audio** | Multimodal models pass through image, PDF, audio, and video inputs. Oversized images auto-resize to 2000×2000 / 5MB to match the gateway contract. | | 📐 **Context-size picker** | Kimi K3 and other tiered-context models expose `256K` vs full-window selection in the per-model configuration, with the cheaper tier selected by default. | -| 🔒 **Your key, your control** | API key stored in VS Code SecretStorage — never leaves your machine | +| 🔒 **Your key, your control** | API key entered once in Language Models → **Add Models…** — stored by VS Code, never leaves your machine | --- @@ -69,7 +69,7 @@ 5. **Click the model picker** (current model name) → **Add Models…** 6. **Select** **OpenCode Go** or **OpenCode Zen**. 7. **Press Enter** to accept the default group name. -8. **Paste your API key** when prompted (stored securely in VS Code SecretStorage). +8. **Paste your API key** when prompted (stored by VS Code in your language-models configuration). 9. **Pick the models** you want enabled. 10. **Select any OpenCode model** from the picker and start chatting. 🚀 @@ -421,11 +421,10 @@ The easiest way to manage your key is **Settings → Language Models** (gear ⚙ | Command | Description | | --------------------------------------------------------- | --------------------------------------------------------------- | -| `OpenCode Go: Manage Provider` | Manage legacy API key, refresh models, test connection | -| `OpenCode Go: Set API Key` | Store/update legacy OpenCode Go API key | +| `OpenCode Go: Manage Provider` | Test connection, refresh models, configure utility models | | `OpenCode Go: Refresh Models` | Force a fresh model-list fetch (bypasses the Manage menu) | | `OpenCode Go: Diagnostics` | Report of Go models + request history | -| `OpenCode Zen: Manage Provider` | Manage Zen API key, refresh models, test connection | +| `OpenCode Zen: Manage Provider` | Test connection, refresh models, configure utility models | | `OpenCode Zen: Refresh Models` | Force a fresh Zen model-list fetch (bypasses the Manage menu) | | `OpenCode Zen: Diagnostics` | Report of Zen models + request history | | `OpenCode: Model Picker Diagnostics` | All registered models (Go + Zen + Copilot) side-by-side | @@ -474,7 +473,7 @@ Inline suggestions, next-edit suggestions, semantic search, and embedding-backed
Where is my API key stored? -In VS Code's **SecretStorage** — the same encrypted store used by GitHub auth. It never leaves your machine and is never sent anywhere except directly to `opencode.ai`. +In your VS Code **language-models configuration** — add it via **Chat: Manage Language Models → Add Models… → OpenCode Go / OpenCode Zen**. VS Code stores the key in its encrypted language-models storage, it never leaves your machine, and it is only sent to `opencode.ai`.
diff --git a/docs/architecture/01-20260514-open-code-provider-architecture.md b/docs/architecture/01-20260514-open-code-provider-architecture.md index f77b14b..19c6877 100644 --- a/docs/architecture/01-20260514-open-code-provider-architecture.md +++ b/docs/architecture/01-20260514-open-code-provider-architecture.md @@ -97,8 +97,7 @@ The native provider configuration schema is declared in `package.json` under `co | Command | Purpose | | --------------------------------------------------------- | -------------------------------------------------------------------------- | -| `OpenCode Go: Manage Provider` | Legacy fallback key management, refresh, and connection test | -| `OpenCode Go: Set API Key` | Legacy fallback key storage | +| `OpenCode Go: Manage Provider` | Refresh models, test connection, configure utility models | | `OpenCode Go: Remove/Re-add Provider in Language Models` | Toggle `opencodego.enabled` (remove/re-add the provider, requires reload) | | `OpenCode Zen: Remove/Re-add Provider in Language Models` | Toggle `opencodezen.enabled` (remove/re-add the provider, requires reload) | | `OpenCode Go: Diagnostics` | Go model and transport diagnostics | @@ -106,7 +105,7 @@ The native provider configuration schema is declared in `package.json` under `co | `OpenCode: Model Picker Diagnostics` | Cross-provider model metadata comparison | | `OpenCode: Set Thinking Effort...` | Global thinking-mode helper for supported families | -The recommended setup path is still VS Code's native **Language Models** UI. The legacy commands remain for diagnostics and fallback compatibility. +The recommended — and only — setup path is VS Code's native **Language Models** UI ("+ Add Models"). The `Set API Key` command and the legacy key-management menu items were removed (the old single `opencodego.apiKey` secret could not represent both Go and Zen keys); the remaining manage commands cover refresh, connection testing, and diagnostics. --- @@ -119,14 +118,14 @@ For model discovery (`provideLanguageModelChatInformation`), the extension resol 1. Read `options.configuration.apiKey` (the native BYOK value) if VS Code supplied one. 2. If step 1 produced nothing, fall back to `SecretStorage` unconditionally. -The unconditional fallback (since 0.5.0, [#86](https://github.com/ltmoerdani/opencode-copilot-chat/issues/86)) covers users who stored the key via the extension command `OpenCode Go: Set API Key` instead of the native BYOK flow. It mirrors Copilot's own `AbstractLanguageModelChatProvider`, which always falls back to its own storage when `configuration.apiKey` is absent. A per-vendor flag (`hasConfiguredByokGroup`) suppresses the groupless call once a native BYOK group exists, so models are not listed twice ([#106](https://github.com/ltmoerdani/opencode-copilot-chat/issues/106)). A group call whose `configuration` is present but carries no API key is treated as a **per-model configuration group** (only `settings`, created when the user picks e.g. `reasoningEffort` in the model picker) and returns no models, so the groupless call remains the single source; per-model settings still apply at request time via `modelConfiguration` ([#131](https://github.com/ltmoerdani/opencode-copilot-chat/issues/131)). +The fallback is an internal mirror of Copilot's own `AbstractLanguageModelChatProvider`, which always falls back to its own storage when `configuration.apiKey` is absent. Since the `Set API Key` command was removed, the only writer is the BYOK group resolution itself: when a non-agent provider resolves a key it persists it into its **per-vendor** secret (`opencodego.apiKey` / `opencodezen.apiKey`, resolved via `secretKeyFor()` in `src/config.ts`), so agent-host variants and cold-start requests inherit it. A per-vendor flag (`hasConfiguredByokGroup`) suppresses the groupless call once a native BYOK group exists, so models are not listed twice ([#106](https://github.com/ltmoerdani/opencode-copilot-chat/issues/106)). A group call whose `configuration` is present but carries no API key is treated as a **per-model configuration group** (only `settings`, created when the user picks e.g. `reasoningEffort` in the model picker) and returns no models, so the groupless call remains the single source; per-model settings still apply at request time via `modelConfiguration` ([#131](https://github.com/ltmoerdani/opencode-copilot-chat/issues/131)). Security rules: - Real API keys are never written to repository files. - Documentation must use placeholders only. -- API keys should be entered through VS Code's native secret-backed provider configuration. -- Legacy `SecretStorage` support remains only as a fallback path. +- API keys are entered through VS Code's native secret-backed provider configuration ("+ Add Models"); there is no command-palette key entry. +- `SecretStorage` remains only as an internal fallback (per-vendor, mirroring the BYOK group key for agent variants and cold-start requests). Safe placeholder example: diff --git a/package.json b/package.json index b228d1c..fc05d57 100644 --- a/package.json +++ b/package.json @@ -66,10 +66,6 @@ "command": "opencodego.manage", "title": "OpenCode Go: Manage Provider" }, - { - "command": "opencodego.setApiKey", - "title": "OpenCode Go: Set API Key" - }, { "command": "opencodego.toggleProvider", "title": "OpenCode Go: Remove/Re-add Provider in Language Models" diff --git a/src/config.ts b/src/config.ts index f0ca714..e947965 100644 --- a/src/config.ts +++ b/src/config.ts @@ -17,8 +17,15 @@ /** VS Code extension ID (used for `extensions.supportAgentsWindow.`). */ export const EXTENSION_ID = "ltmoerdani.opencode-copilot-chat"; -/** SecretStorage key for the API key. */ +/** SecretStorage key for the OpenCode Go API key (legacy name preserved). */ export const SECRET_KEY = "opencodego.apiKey"; +/** SecretStorage key for the OpenCode Zen API key (per-vendor, so Go and Zen + * never overwrite each other's key). */ +export const ZEN_SECRET_KEY = "opencodezen.apiKey"; +/** Resolve the SecretStorage key for a provider vendor. */ +export function secretKeyFor(vendor: "opencodego" | "opencodezen"): string { + return vendor === "opencodezen" ? ZEN_SECRET_KEY : SECRET_KEY; +} /** Client name sent in the `x-opencode-client` header. */ export const OPEN_CODE_CLIENT = "vscode-copilot-chat"; /** Fallback only — overridden at runtime from packageJSON.version. */ diff --git a/src/extension.ts b/src/extension.ts index 9ffc6c1..3e0731f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -85,7 +85,7 @@ import { OPEN_CODE_CLIENT, RECENT_TRANSPORT_SUMMARY_LIMIT, RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX, - SECRET_KEY, + secretKeyFor, SETTING_AGENTS_WINDOW, SETTING_AUTO_ENABLE_AGENTS_WINDOW, SETTING_DEBUG_REASONING, @@ -732,7 +732,7 @@ export function activate(context: vscode.ExtensionContext) { } // Pull the server-accurate account meters once at startup (TTL-guarded). void (async () => { - const apiKey = await context.secrets.get(SECRET_KEY); + const apiKey = await context.secrets.get(secretKeyFor(GO_VENDOR)); if (!apiKey) return; await syncTrackerUsage(getOrCreateTracker(keyFingerprint(apiKey)), apiKey); })(); @@ -755,7 +755,6 @@ export function activate(context: vscode.ExtensionContext) { ...(zenProviderEnabled ? [vscode.lm.registerLanguageModelChatProvider(ZEN_VENDOR, zenProvider)] : []), vscode.commands.registerCommand("opencodego.manage", () => goProvider.manage()), vscode.commands.registerCommand("opencodego.diagnostics", () => goProvider.showDiagnostics()), - vscode.commands.registerCommand("opencodego.setApiKey", () => goProvider.setApiKey()), vscode.commands.registerCommand("opencodego.refreshModels", () => goProvider.refreshModels()), vscode.commands.registerCommand("opencodego.toggleProvider", () => toggleProviderEnabled(GO_VENDOR, "OpenCode Go")), vscode.commands.registerCommand("opencodego.configureUtilityModels", () => configureUtilityModels()), @@ -993,7 +992,7 @@ export function activate(context: vscode.ExtensionContext) { chatCompletionsUrl: PROVIDERS[GO_VENDOR].chatCompletionsUrl, // Same resolution order as the chat path: the active profile's own key // first (covers multi-profile / BYOK-group setups), then the secret. - resolveApiKey: async () => profileApiKeys.get(activeProfileFingerprint) ?? _extensionContext?.secrets.get(SECRET_KEY), + resolveApiKey: async () => profileApiKeys.get(activeProfileFingerprint) ?? _extensionContext?.secrets.get(secretKeyFor(GO_VENDOR)), }); } @@ -1296,7 +1295,7 @@ function refreshGoUsageStatusBar(): void { // a new snapshot lands, rebuild the status bar with it. Use the active // profile's own key when known, falling back to the extension secret. void (async () => { - const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(SECRET_KEY)); + const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(secretKeyFor(GO_VENDOR))); if (!apiKey) return; const changed = await tracker.syncServerUsage(apiKey); if (changed) refreshGoUsageStatusBar(); @@ -2298,10 +2297,6 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { await this.context.globalState.update(this.byokGroupStateKey, true); } - - private async clearByokGroupConfigured(): Promise { - await this.context.globalState.update(this.byokGroupStateKey, undefined); - } /** Capped to prevent unbounded growth across long sessions. */ private readonly reasoningContentByToolCallId = new Map(); private static readonly REASONING_CACHE_LIMIT = 500; @@ -2476,7 +2471,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { - const apiKey = await this.context.secrets.get(SECRET_KEY); + const apiKey = await this.context.secrets.get(secretKeyFor(this.baseVendor)); if (!apiKey) { - await this.setApiKey(); + vscode.window.showErrorMessage( + `${this.definition.displayName}: No API key configured. Add the provider via Manage Language Models ("+ Add Models" → ${this.definition.displayName}) first.`, + ); return; } await this.refreshMetadataAndModels(); @@ -2511,8 +2510,6 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider(providerEnabledSetting(this.definition.vendor), true); const choice = await vscode.window.showQuickPick( [ - { label: "Set API Key", action: "set" as const }, - { label: "Clear API Key", action: "clear" as const }, { label: "Test Connection", action: "test" as const }, { label: "Refresh Models", action: "refresh" as const }, { label: "Configure Utility Models", action: "utility" as const }, @@ -2536,26 +2533,6 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { - const apiKey = await this.context.secrets.get(SECRET_KEY); + const apiKey = await this.context.secrets.get(secretKeyFor(this.baseVendor)); if (!apiKey) { - vscode.window.showErrorMessage(`${this.definition.displayName}: No API key set. Use 'Set API Key' first.`); + vscode.window.showErrorMessage( + `${this.definition.displayName}: No API key configured. Add the provider via Manage Language Models ("+ Add Models" → ${this.definition.displayName}) first.`, + ); return; } @@ -2621,24 +2600,6 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { - const apiKey = await vscode.window.showInputBox({ - title: `${this.definition.displayName} API Key`, - prompt: `Paste your ${this.definition.displayName} API key. It will be stored securely in VS Code SecretStorage.`, - password: true, - ignoreFocusOut: true, - }); - - const normalizedApiKey = apiKey?.trim(); - if (!normalizedApiKey) { - return; - } - - await this.context.secrets.store(SECRET_KEY, normalizedApiKey); - this.changeEmitter.fire(); - vscode.window.showInformationMessage(`${this.definition.displayName} API key saved.`); - } - async showDiagnostics(): Promise { let models: readonly vscode.LanguageModelChat[] = []; let modelSelectionError: string | undefined; @@ -2648,7 +2609,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { const rawModelId = resolveRawModelId(model.id); @@ -2758,7 +2719,7 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider { }); }); +describe("config — secret keys", () => { + it("resolves per-vendor SecretStorage keys so Go and Zen never collide", () => { + assert.equal(secretKeyFor("opencodego"), SECRET_KEY); + assert.equal(secretKeyFor("opencodezen"), ZEN_SECRET_KEY); + assert.notEqual(secretKeyFor("opencodego"), secretKeyFor("opencodezen")); + }); +}); + describe("config — timeouts are sane", () => { it("keeps request timeouts positive and ordered sensibly", () => { expectValue( From ea4947d61deb6a4ff9334f75391ccc1e951dfec1 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 14:13:47 +0800 Subject: [PATCH 09/22] refactor(usage): split goUsageTracker into src/usage/ modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 1510-line goUsageTracker.ts god file into the usage domain: - usage/tracker.ts — GoUsageTracker class + types + time-window helpers - usage/history.ts — OpenCode CLI SQLite history read/aggregation (pure) - usage/pricing.ts — bundled pricing snapshot + estimateCost (pure) - usage/formatting.ts — status-bar / quick-pick formatting Move usage.ts / usageProfile.ts / goUsageSync.ts into src/usage/ and keep goUsageTracker.ts as a thin barrel re-exporting the historical public API. Behavior-preserving; all importers + tests updated. --- src/chatParts.ts | 2 +- src/contextWindowHook.ts | 2 +- src/contextWindowHookBridge.ts | 2 +- src/extension.ts | 4 +- src/goUsageTracker.ts | 1553 +------------------------------ src/streaming.ts | 2 +- src/test/goUsageSync.test.ts | 2 +- src/test/goUsageTracker.test.ts | 2 +- src/test/usageProfile.test.ts | 4 +- src/usage/formatting.ts | 126 +++ src/{ => usage}/goUsageSync.ts | 6 +- src/usage/history.ts | 373 ++++++++ src/usage/pricing.ts | 52 ++ src/usage/tracker.ts | 985 ++++++++++++++++++++ src/{ => usage}/usage.ts | 4 +- src/{ => usage}/usageProfile.ts | 4 +- 16 files changed, 1583 insertions(+), 1540 deletions(-) create mode 100644 src/usage/formatting.ts rename src/{ => usage}/goUsageSync.ts (96%) create mode 100644 src/usage/history.ts create mode 100644 src/usage/pricing.ts create mode 100644 src/usage/tracker.ts rename src/{ => usage}/usage.ts (99%) rename src/{ => usage}/usageProfile.ts (96%) diff --git a/src/chatParts.ts b/src/chatParts.ts index b3c402b..7339e95 100644 --- a/src/chatParts.ts +++ b/src/chatParts.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import { hasUsageSnapshot, toProviderUsagePayload, type UsageSnapshot } from "./usage"; +import { hasUsageSnapshot, toProviderUsagePayload, type UsageSnapshot } from "./usage/usage"; export const OPENCODE_USAGE_DATA_MIME = "application/vnd.opencode.usage+json"; export const COPILOT_USAGE_DATA_MIME = "usage"; diff --git a/src/contextWindowHook.ts b/src/contextWindowHook.ts index bc5e149..2689777 100644 --- a/src/contextWindowHook.ts +++ b/src/contextWindowHook.ts @@ -1,6 +1,6 @@ import { AsyncLocalStorage } from "node:async_hooks"; import * as vscode from "vscode"; -import type { UsageSnapshot } from "./usage"; +import type { UsageSnapshot } from "./usage/usage"; import { CONTEXT_HOOK_PROBE_DELAY_MS } from "./config"; import { getErrorMessage, isRecord } from "./utils"; diff --git a/src/contextWindowHookBridge.ts b/src/contextWindowHookBridge.ts index a372f68..64ec15f 100644 --- a/src/contextWindowHookBridge.ts +++ b/src/contextWindowHookBridge.ts @@ -1,5 +1,5 @@ import type { LanguageModelResponsePart2, Progress } from "vscode"; -import type { UsageSnapshot } from "./usage"; +import type { UsageSnapshot } from "./usage/usage"; import { getErrorMessage } from "./utils"; type ContextWindowHookModule = typeof import("./contextWindowHook.js"); diff --git a/src/extension.ts b/src/extension.ts index 3e0731f..c479d75 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -145,7 +145,7 @@ import { } from "./utils"; import { isFreeModel } from "./metadata"; -import { formatCacheHitRatio, formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage"; +import { formatCacheHitRatio, formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage/usage"; import { GoUsageTracker, GO_LIMITS, @@ -170,7 +170,7 @@ import { renameProfile, nonLegacyCount, type UsageProfile, -} from "./usageProfile"; +} from "./usage/usageProfile"; /** * VS Code core settings the extension manages (auto-configures and reverts) diff --git a/src/goUsageTracker.ts b/src/goUsageTracker.ts index 9addf7b..594a38e 100644 --- a/src/goUsageTracker.ts +++ b/src/goUsageTracker.ts @@ -1,1525 +1,32 @@ -import * as vscode from "vscode"; -import * as path from "path"; -import * as os from "os"; -import * as fs from "fs"; -import { execFileSync } from "child_process"; -import { GO_VENDOR } from "./providerTypes"; -import type { ModelCost } from "./metadata"; -import type { TransportRequestSummary } from "./streaming"; -import { fetchGoUsage, mergeServerUsage, GO_USAGE_SYNC_TTL_MS, type GoUsageApiResponse } from "./goUsageSync"; -import { - GO_LIMITS, - FIVE_HOURS_MS, - WEEK_MS, - GO_USAGE_LOG_KEY, - GO_USAGE_BASELINE_KEY, - GO_EVER_TRACKED_KEY, - GO_SESSION_COSTS_KEY, - GO_MAX_LOG_ENTRIES, - GO_SESSION_IDLE_MS, - GO_MAX_SESSIONS, - GO_SERVER_USAGE_KEY, - type UsageTodayYesterdaySource, -} from "./config"; -import { formatCount, formatTokenCount, formatUsd, formatRelativeTime, getErrorMessage } from "./utils"; - -export { GO_LIMITS } from "./config"; - -/** Callback to resolve live model cost from the models.dev metadata cache. */ -export type CostResolver = (modelId: string) => ModelCost | undefined; - -// ─── Constants (values centralized in ./config) ────────────────────────────── - -const STORAGE_KEY = GO_USAGE_LOG_KEY; -const BASELINE_STORAGE_KEY = GO_USAGE_BASELINE_KEY; -const EVER_TRACKED_KEY = GO_EVER_TRACKED_KEY; -const SESSION_COSTS_KEY = GO_SESSION_COSTS_KEY; -const MAX_LOG_ENTRIES = GO_MAX_LOG_ENTRIES; - -// ─── Go model pricing ($/1M tokens) — bundled snapshot fallback ──────────── -// This table is a static snapshot kept as a last resort. The primary source -// is the live models.dev metadata cache injected via CostResolver. - -const GO_MODEL_PRICING: Record = { - "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, - "kimi-k2.6": { input: 0.95, output: 4.0, cache_read: 0.16 }, - "kimi-k2.5": { input: 0.6, output: 3.0, cache_read: 0.1 }, - "minimax-m3": { input: 0.6, output: 2.4, cache_read: 0.12 }, - "minimax-m2.7": { input: 0.3, output: 1.2, cache_read: 0.06 }, - "minimax-m2.5": { input: 0.3, output: 1.2, cache_read: 0.06 }, - "mimo-v2.5": { input: 0.14, output: 0.28, cache_read: 0.003 }, - "mimo-v2.5-pro": { input: 1.74, output: 3.48, cache_read: 0.015 }, - "mimo-v2-omni": { input: 0.14, output: 0.28, cache_read: 0.003 }, - "mimo-v2-pro": { input: 1.74, output: 3.48, cache_read: 0.015 }, - "qwen3.7-max": { input: 2.5, output: 7.5, cache_read: 0.5 }, - "qwen3.7-plus": { input: 0.4, output: 1.6, cache_read: 0.04 }, - "qwen3.6-plus": { input: 0.5, output: 3.0, cache_read: 0.05 }, - "qwen3.5-plus": { input: 0.2, output: 1.2, cache_read: 0.02 }, - "deepseek-v4-pro": { input: 1.74, output: 3.48, cache_read: 0.015 }, - "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.003 }, - "hy3-preview": { input: 0.5, output: 1.5, cache_read: 0.05 }, -}; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -export interface UsageLogEntry { - /** Unix timestamp ms */ - timestamp: number; - modelId: string; - /** Estimated cost in USD */ - cost: number; - promptTokens: number; - completionTokens: number; - cachedTokens: number; - /** Chat session identifier (stable hash per conversation thread). */ - sessionId?: string; - /** Credits for VS Code session cost (1 credit = $0.01). */ - copilotCredits?: number; -} - -/** Aggregated cost for a single chat session. */ -export interface SessionCostSummary { - sessionId: string; - cost: number; - requests: number; - promptTokens: number; - completionTokens: number; - lastActivity: number; -} - -export interface PeriodUsage { - spent: number; - limit: number; - percent: number; - resetsAt: Date; -} - -export interface UsageSummary { - session: PeriodUsage; - weekly: PeriodUsage; - monthly: PeriodUsage; - today: UsageDaily; - yesterday: UsageDaily; - /** All-time usage in the CURRENT workspace (from OpenCode CLI history). */ - codebase: UsageDaily; - hasData: boolean; - /** When true, cost data comes from the OpenCode CLI SQLite database - (actual billed amounts). When false, costs are estimated locally. */ - sqliteAvailable: boolean; -} - -/** - * Per-view knobs resolved live so the user can pick how usage is presented. - * All resolvers are optional — the tracker falls back to sensible defaults. - */ -export interface GoUsageTrackerOptions { - /** Absolute paths of the current VS Code workspace folders. */ - resolveWorkspaceFolders?: () => readonly string[]; - /** Source of the Today/Yesterday rows (default "auto"). */ - resolveTodayYesterdaySource?: () => UsageTodayYesterdaySource; - /** Codebase window in days; 0 = forever (default). */ - resolveCodebaseWindowDays?: () => number; - /** Day boundary for Today/Yesterday ("utc" default | "local"). */ - resolveDayBoundary?: () => "utc" | "local"; -} - -interface UsageBaselinePeriod { - amount: number; - expiresAt: number; -} - -interface UsageBaseline { - session?: UsageBaselinePeriod; - weekly?: UsageBaselinePeriod; - monthly?: UsageBaselinePeriod & { - /** The user's billing anchor day (1-31) for the monthly reset. */ - anchorDay?: number; - /** The user's billing anchor hour (0-23 UTC) for the monthly reset. */ - anchorHour?: number; - }; -} - -export interface UsageBaselineTargets { - session: number; - weekly: number; - monthly: number; - /** Day of month (1-31) when monthly counter resets. Combined with monthlyAnchorHour. */ - monthlyAnchorDay?: number; - /** Hour of day (0-23 UTC) when monthly counter resets. Combined with monthlyAnchorDay. */ - monthlyAnchorHour?: number; -} - -// ─── Time window helpers ───────────────────────────────────────────────────── - -function startOfUtcDay(nowMs: number): number { - const d = new Date(nowMs); - return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); -} - -/** Start of the LOCAL day — used when `usageDayBoundary` is set to "local". */ -export function startOfLocalDay(nowMs: number): number { - const d = new Date(nowMs); - return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); -} - -/** Normalize a directory path for matching (trailing separators, Windows case). */ -export function normalizeCwd(value: string): string { - let normalized = value.replace(/[\/]+$/, ""); - if (process.platform === "win32") { - normalized = normalized.toLowerCase(); - } - return normalized; -} - -/** Whether `value` starts with `prefix` followed by a path separator. */ -function startsWithPathSegment(value: string, prefix: string): boolean { - if (!value.startsWith(prefix)) { - return false; - } - return value.length > prefix.length && (value.charAt(prefix.length) === "/" || value.charAt(prefix.length) === "\\"); -} - -/** - * Whether a CLI row's working directory belongs to the current workspace. - * Matches when the folder equals the cwd, is a parent of it (the user opened - * the repo root but the CLI ran in a subfolder), or the folder is a subfolder - * of the cwd (the user opened a subfolder of the project). - * - * Segment-boundary matching accepts both `/` and `\` so POSIX-style paths and - * native Windows paths (where the separator is `\`) both match on any host. - */ -export function isCwdInWorkspace(cwd: string | undefined, workspaceFolders: readonly string[]): boolean { - if (!cwd || workspaceFolders.length === 0) { - return false; - } - const rowCwd = normalizeCwd(cwd); - for (const folder of workspaceFolders) { - const normalized = normalizeCwd(folder); - if (rowCwd === normalized) return true; - if (startsWithPathSegment(rowCwd, normalized)) return true; - if (startsWithPathSegment(normalized, rowCwd)) return true; - } - return false; -} - -function startOfUtcWeek(nowMs: number): number { - const d = new Date(nowMs); - const offset = (d.getUTCDay() + 6) % 7; // Monday=0 - d.setUTCDate(d.getUTCDate() - offset); - d.setUTCHours(0, 0, 0, 0); - return d.getTime(); -} - -function anchoredMonthStart(nowMs: number, anchorDay: number, anchorHour: number): number { - const now = new Date(nowMs); - let year = now.getUTCFullYear(); - let month = now.getUTCMonth(); - let candidate = Date.UTC(year, month, anchorDay, anchorHour, 0, 0, 0); - if (candidate > nowMs) { - if (month === 0) { - year--; - month = 11; - } else { - month--; - } - candidate = Date.UTC(year, month, anchorDay, anchorHour, 0, 0, 0); - } - return candidate; -} - -function anchoredMonthEnd(startMs: number, anchorDay: number, anchorHour: number): number { - const d = new Date(startMs); - let year = d.getUTCFullYear(); - let month = d.getUTCMonth() + 1; - if (month > 11) { - year++; - month = 0; - } - return Date.UTC(year, month, anchorDay, anchorHour, 0, 0, 0); -} - -/** Build the monthly window: manual anchor > auto-anchor from earliest row > calendar month. */ -function buildMonthlyWindow( - nowMs: number, - baseline: UsageBaseline, - earliestMs?: number | null, -): { monthStartMs: number; monthEndMs: number } { - // Priority 1: user-configured anchor (set via "Set spent targets") - const monthly = baseline.monthly; - const monthlyAnchor = monthly?.anchorDay; - if (monthly && monthlyAnchor && monthlyAnchor >= 1 && monthlyAnchor <= 31) { - const hour = monthly.anchorHour ?? 0; - const start = anchoredMonthStart(nowMs, monthlyAnchor, hour); - const end = anchoredMonthEnd(start, monthlyAnchor, hour); - return { monthStartMs: start, monthEndMs: end }; - } - // Priority 2: auto-anchor from earliest SQLite row (actual billing start) - if (earliestMs != null) { - const d = new Date(earliestMs); - const day = d.getUTCDate(); - const hour = d.getUTCHours(); - const start = anchoredMonthStart(nowMs, day, hour); - const end = anchoredMonthEnd(start, day, hour); - return { monthStartMs: start, monthEndMs: end }; - } - // Fallback: calendar month - const now = new Date(nowMs); - return { - monthStartMs: Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), - monthEndMs: Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1), - }; -} - -/** Rolling reset: oldest entry in the current 5h window + 5h */ -function nextSessionReset(entries: UsageLogEntry[], nowMs: number): Date { - const windowStart = nowMs - FIVE_HOURS_MS; - let oldest: number | null = null; - for (const e of entries) { - if (e.timestamp >= windowStart && e.timestamp < nowMs) { - if (oldest === null || e.timestamp < oldest) oldest = e.timestamp; - } - } - return new Date((oldest ?? nowMs) + FIVE_HOURS_MS); -} - -// ─── Cost calculation ──────────────────────────────────────────────────────── - -/** Priority: caller-provided cost > live models.dev snapshot > bundled table */ -export function estimateCost( - modelId: string, - promptTokens: number, - completionTokens: number, - cachedTokens: number, - externalCost?: ModelCost, - liveCostResolver?: CostResolver, -): number { - // Priority: caller-provided cost > live models.dev snapshot > bundled table - const pricing = externalCost ?? liveCostResolver?.(modelId) ?? GO_MODEL_PRICING[modelId]; - if (!pricing) return 0; - - const billablePrompt = Math.max(0, promptTokens - cachedTokens); - return ( - (billablePrompt * pricing.input) / 1_000_000 + - (completionTokens * pricing.output) / 1_000_000 + - (cachedTokens * (pricing.cache_read ?? pricing.input * 0.1)) / 1_000_000 - ); -} - -// ─── OpenCode SQLite history reader (same source as OpenUsage) ─────────────── -// Reads from ~/.local/share/opencode/opencode.db -// SQL from https://github.com/robinebers/openusage/plugins/opencode-go/plugin.js - -const OPENCODE_DB_PATH = path.join(os.homedir(), ".local", "share", "opencode", "opencode.db"); - -const HISTORY_ROWS_SQL = ` - SELECT - CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, - CAST(json_extract(data, '$.cost') AS REAL) AS cost, - CAST(json_extract(data, '$.tokens.input') AS INTEGER) AS tokensInput, - CAST(json_extract(data, '$.tokens.output') AS INTEGER) AS tokensOutput, - CAST(json_extract(data, '$.tokens.reasoning') AS INTEGER) AS tokensReasoning, - CAST(json_extract(data, '$.tokens.cache.read') AS INTEGER) AS tokensCacheRead, - json_extract(data, '$.path.cwd') AS cwd, - json_extract(data, '$.modelID') AS modelId - FROM message - WHERE json_valid(data) - AND json_extract(data, '$.providerID') = 'opencode-go' - AND json_extract(data, '$.role') = 'assistant' - AND json_type(data, '$.cost') IN ('integer', 'real') -`; - -export interface HistoryRow { - createdMs: number; - cost: number; - tokensInput: number; - tokensOutput: number; - tokensReasoning: number; - tokensCacheRead: number; - /** Working directory of the session the message belongs to (OpenCode CLI data). */ - cwd?: string; - /** Model that produced the message (OpenCode CLI data). */ - modelId?: string; - /** - * Total tokens for the message: input + output + reasoning + cache.read. - * The CLI's `tokens.input` EXCLUDES cached tokens — the authoritative - * `tokens.total` matches input + output + reasoning + cache.read — so this - * sum is what any "tokens used" display must count (parity with the - * extension's own promptTokens, which include cached tokens). - */ - tokensTotal: number; -} - -/** Non-negative finite integer (tokens can legitimately be 0). */ -function positiveNumberish(value: unknown): number { - return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; -} - -/** - * Sum one day window across the OpenCode CLI history rows and the extension's - * tracked entries. The CLI records terminal usage and the extension records - * VS Code usage — they never overlap, so the sum is the user's real combined - * usage for the window. `source` selects which inputs participate. - */ -export function sumDailyUsage( - rows: HistoryRow[], - entries: UsageLogEntry[], - dayStartMs: number, - source: UsageTodayYesterdaySource = "auto", -): UsageDaily { - let cost = 0; - let requests = 0; - let tokens = 0; - - if (source !== "extension") { - for (const row of rows) { - if (row.createdMs < dayStartMs) continue; - cost += row.cost; - requests += 1; - tokens += row.tokensTotal; - } - } - - if (source !== "cli") { - for (const entry of entries) { - if (entry.timestamp < dayStartMs) continue; - cost += entry.cost; - requests += 1; - tokens += entry.promptTokens + entry.completionTokens; - } - } - - return { cost, requests, tokens }; -} - -/** Per-day / per-workspace usage totals (from CLI history and/or extension tracking). */ -export interface UsageDaily { - cost: number; - requests: number; - tokens: number; -} - -/** One day bucket of the usage chart. */ -export interface UsageDayPoint { - /** Unix ms at the START of the day (UTC or local, per the day-boundary setting). */ - dayStart: number; - cost: number; - tokens: number; - requests: number; -} - -/** Per-model usage for a single day (model bar chart). */ -export interface ModelDayUsage { - model: string; - dayStart: number; - cost: number; - tokens: number; - requests: number; -} - -/** Time-series data for the usage panel charts. */ -export interface UsageSeries { - /** Daily totals, oldest → newest. */ - days: UsageDayPoint[]; - /** Per-model-per-day rows (only days with usage are present). */ - byModel: ModelDayUsage[]; -} - -const DAY_MS = 24 * 60 * 60 * 1000; - /** - * Bucket CLI rows + extension entries into per-day totals and per-model - * per-day rows over the last `days` days (the oldest bucket starts at - * `dayStartMs - (days - 1) * DAY_MS`). Pure so it can be unit-tested. + * Barrel — the Go usage tracker was split into `src/usage/` (tracker, + * history, pricing, formatting, usage, usageProfile, goUsageSync). This + * module re-exports the historical public API so existing importers keep + * working during the refactor. */ -export function buildUsageSeries( - rows: HistoryRow[], - entries: UsageLogEntry[], - days: number, - dayStartMs: number, - source: UsageTodayYesterdaySource = "auto", -): UsageSeries { - // days > 0: the last `days` days ending at dayStartMs; days <= 0: lifetime - // from the earliest recorded usage to today (aligned to the day grid). - let firstDay: number; - if (days > 0) { - firstDay = dayStartMs - (Math.max(1, Math.floor(days)) - 1) * DAY_MS; - } else { - let earliest = dayStartMs; - if (source !== "extension") { - for (const row of rows) if (row.createdMs < earliest) earliest = row.createdMs; - } - if (source !== "cli") { - for (const entry of entries) if (entry.timestamp < earliest) earliest = entry.timestamp; - } - firstDay = dayStartMs - Math.ceil((dayStartMs - earliest) / DAY_MS) * DAY_MS; - } - const bucketCount = Math.round((dayStartMs - firstDay) / DAY_MS) + 1; - const buckets: UsageDayPoint[] = Array.from({ length: bucketCount }, (_, i) => ({ - dayStart: firstDay + i * DAY_MS, - cost: 0, - tokens: 0, - requests: 0, - })); - const byModel = new Map>(); - - const add = (model: string | undefined, timestamp: number, cost: number, tokens: number): void => { - const index = Math.round((timestamp - firstDay) / DAY_MS); - if (index < 0 || index >= bucketCount) return; - const day = buckets[index]; - day.cost += cost; - day.tokens += tokens; - day.requests += 1; - - const modelName = model ?? "unknown"; - let byDay = byModel.get(modelName); - if (!byDay) { - byDay = new Map(); - byModel.set(modelName, byDay); - } - const point = byDay.get(index) ?? { model: modelName, dayStart: day.dayStart, cost: 0, tokens: 0, requests: 0 }; - point.cost += cost; - point.tokens += tokens; - point.requests += 1; - byDay.set(index, point); - }; - - if (source !== "extension") { - for (const row of rows) { - add(row.modelId, row.createdMs, row.cost, row.tokensTotal); - } - } - if (source !== "cli") { - for (const entry of entries) { - add(entry.modelId, entry.timestamp, entry.cost, entry.promptTokens + entry.completionTokens); - } - } - - return { - days: buckets, - byModel: [...byModel.entries()].flatMap(([, byDay]) => - [...byDay.entries()].sort((left, right) => left[0] - right[0]).map(([, point]) => point), - ), - }; -} - -/** - * The CLI database can be gigabytes large and spawning `sqlite3` is a - * synchronous, blocking call — but the usage UI (status bar, tooltip, panel, - * quick-pick) re-reads it on every refresh. Memoize the result for a short - * window so a burst of refreshes pays the query cost once. - */ -const HISTORY_READ_TTL_MS = 3_000; -let historyCache: { rows: HistoryRow[] | null; fetchedAt: number } | undefined; -/** Surfaces CLI-history read failures in the usage output channel. */ -let historyReadDiagnostic: ((message: string) => void) | undefined; - -/** Wire the diagnostic sink (called once per tracker, last one wins). */ -export function setHistoryReadDiagnostic(log: (message: string) => void): void { - historyReadDiagnostic = log; -} - -function readOpenCodeHistory(): HistoryRow[] | null { - const now = Date.now(); - if (historyCache && now - historyCache.fetchedAt < HISTORY_READ_TTL_MS) { - return historyCache.rows; - } - const rows = readOpenCodeHistoryUncached(); - historyCache = { rows, fetchedAt: now }; - return rows; -} - -function readOpenCodeHistoryUncached(): HistoryRow[] | null { - if (!fs.existsSync(OPENCODE_DB_PATH)) { - historyReadDiagnostic?.(`[go-usage] CLI history: database not found at ${OPENCODE_DB_PATH}`); - return null; - } - - // The `sqlite3` binary may be missing from the extension host's PATH (it is - // often only available from the Android SDK, e.g. launched from a terminal), - // so Node's built-in reader is tried first — zero external dependencies. - const viaNode = readHistoryViaNodeSqlite(); - if (viaNode !== undefined) { - return viaNode; - } - - return readHistoryViaSqliteCli(); -} - -/** Normalize raw rows (shared by both readers). */ -function normalizeHistoryRows(rows: unknown): HistoryRow[] { - if (!Array.isArray(rows)) return []; - return rows - .filter((row): row is HistoryRow => { - if (!row || typeof row !== "object") return false; - const candidate = row as Partial; - return ( - typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 - ); - }) - .map((row) => { - const tokensInput = positiveNumberish(row.tokensInput); - const tokensOutput = positiveNumberish(row.tokensOutput); - const tokensReasoning = positiveNumberish(row.tokensReasoning); - const tokensCacheRead = positiveNumberish(row.tokensCacheRead); - return { - createdMs: row.createdMs, - cost: row.cost, - tokensInput, - tokensOutput, - tokensReasoning, - tokensCacheRead, - tokensTotal: tokensInput + tokensOutput + tokensReasoning + tokensCacheRead, - cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, - modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, - }; - }); -} - -/** - * Read the CLI history with Node's built-in `node:sqlite` (no binary on the - * host PATH needed). Returns `undefined` when the module is unavailable on - * this host so the caller can fall back to the `sqlite3` binary. - */ -function readHistoryViaNodeSqlite(): HistoryRow[] | null | undefined { - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { DatabaseSync } = require("node:sqlite") as { - DatabaseSync?: new ( - path: string, - options?: { readOnly?: boolean }, - ) => { - prepare(sql: string): { all(): Record[] }; - close(): void; - }; - }; - if (typeof DatabaseSync !== "function") { - return undefined; - } - // Transient busy/lock states (CLI checkpointing the WAL) resolve quickly. - for (let attempt = 0; attempt < 2; attempt++) { - try { - const db = new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true }); - try { - const rows = db.prepare(HISTORY_ROWS_SQL).all(); - return rows.length > 0 ? normalizeHistoryRows(rows) : null; - } finally { - db.close(); - } - } catch (error) { - const message = getErrorMessage(error); - if (attempt === 0) { - historyReadDiagnostic?.(`[go-usage] node:sqlite read failed (attempt 1): ${message}. Retrying…`); - } else { - historyReadDiagnostic?.(`[go-usage] node:sqlite read failed: ${message}`); - } - } - } - return null; - } catch (error) { - historyReadDiagnostic?.(`[go-usage] node:sqlite unavailable (${getErrorMessage(error)}); falling back to the sqlite3 binary.`); - return undefined; - } -} - -/** - * Candidate `sqlite3` binaries: the PATH-resolved name first, then absolute - * paths from common installs (system, Homebrew, Android SDK) — the Android - * SDK binary is what most dev machines actually have, and it is frequently - * missing from the extension host's PATH. - */ -function sqliteCliCandidates(): string[] { - const home = os.homedir(); - return [ - "sqlite3", - "/usr/bin/sqlite3", - "/usr/local/bin/sqlite3", - "/opt/homebrew/bin/sqlite3", - path.join(home, "Android", "Sdk", "platform-tools", "sqlite3"), - path.join(home, "Library", "Android", "sdk", "platform-tools", "sqlite3"), - ]; -} - -function readHistoryViaSqliteCli(): HistoryRow[] | null { - for (const binary of sqliteCliCandidates()) { - for (let attempt = 0; attempt < 2; attempt++) { - try { - const result = execFileSync(binary, ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { - timeout: 10_000, - maxBuffer: 64 * 1024 * 1024, - encoding: "utf-8", - stdio: ["pipe", "pipe", "pipe"], - }); - const rows: unknown = JSON.parse(result); - return Array.isArray(rows) ? normalizeHistoryRows(rows) : null; - } catch (error) { - const message = getErrorMessage(error); - // ENOENT just means this candidate isn't present — try the next one. - if (attempt === 0 && message.includes("ENOENT")) { - break; - } - if (attempt === 0) { - historyReadDiagnostic?.(`[go-usage] sqlite3 read failed (attempt 1): ${message}. Retrying…`); - } else { - historyReadDiagnostic?.(`[go-usage] sqlite3 read failed (${binary}): ${message}`); - } - } - } - } - historyReadDiagnostic?.( - "[go-usage] CLI history unavailable: no SQLite reader found (node:sqlite missing and no sqlite3 binary on PATH).", - ); - return null; -} - -// ─── Exported tracker class ────────────────────────────────────────────────── - -export class GoUsageTracker { - private entries: UsageLogEntry[] = []; - /** - * Whether this profile has ever recorded (or had cleared) local usage. - * Kept true after a reset so the usage card shows zeroed local values - * instead of collapsing into the first-run "no data" state. - */ - private everTracked = false; - private baseline: UsageBaseline = {}; - private readonly log?: (msg: string) => void; - private costResolver?: CostResolver; - /** Per-chat-session cost accumulator. Key = sessionId. */ - private sessionCosts = new Map(); - /** Latest server-accurate usage snapshot (account-wide meters). */ - private serverUsage: GoUsageApiResponse | undefined; - /** Unix ms of the last successful {@link syncServerUsage} fetch. */ - private serverUsageFetchedAt = 0; - /** In-flight sync promise per key — prevents duplicate concurrent fetches. */ - private syncInFlight: { apiKey: string; promise: Promise } | undefined; - private static readonly SESSION_IDLE_MS = GO_SESSION_IDLE_MS; - private static readonly MAX_SESSIONS = GO_MAX_SESSIONS; - - constructor( - private readonly context: vscode.ExtensionContext, - log?: (msg: string) => void, - costResolver?: CostResolver, - /** - * Per-profile storage suffix. When set, storage keys are namespaced - * so multiple Go accounts can coexist. Empty string = legacy mode - * (single account, shared key). - */ - private readonly storageKeySuffix = "", - private readonly options: GoUsageTrackerOptions = {}, - ) { - this.log = log; - this.costResolver = costResolver; - if (log) { - setHistoryReadDiagnostic(log); - } - this.restore(); - // Fast startup: show the last successful server snapshot immediately - // instead of 0s until the TTL-guarded refetch lands. `serverUsageFetchedAt` - // stays 0, so the background sync still refreshes right away. - this.serverUsage = this.context.globalState.get(this.storageKey(GO_SERVER_USAGE_KEY)); - } - - private storageKey(base: string): string { - return this.storageKeySuffix ? `${base}.${this.storageKeySuffix}` : base; - } - - /** - * Copy all data from the singleton legacy keys (without suffix) into - * this profile's namespaced storage. Called once during the first - * activation after a single-account user upgrades to multi-account. - */ - migrateFromSingleton(): void { - if (!this.storageKeySuffix) return; // i am the singleton - const hasLegacyEntries = this.context.globalState.get(STORAGE_KEY, []).length > 0; - if (!hasLegacyEntries) return; - - this.log?.("[go-tracker] migrating legacy singleton data into profile"); - - // Migrate entries - const legacyEntries = this.context.globalState.get(STORAGE_KEY, []); - if (Array.isArray(legacyEntries) && legacyEntries.length > 0) { - const targetKey = this.storageKey(STORAGE_KEY); - this.context.globalState.update(targetKey, legacyEntries); - this.context.globalState.update(STORAGE_KEY, []); - this.entries = legacyEntries.filter((e) => typeof e.timestamp === "number" && typeof e.cost === "number"); - } - - // Migrate baseline - const legacyBaseline = this.context.globalState.get(BASELINE_STORAGE_KEY, {}); - if (Object.keys(legacyBaseline).length > 0) { - const targetBase = this.storageKey(BASELINE_STORAGE_KEY); - this.context.globalState.update(targetBase, legacyBaseline); - this.context.globalState.update(BASELINE_STORAGE_KEY, {}); - this.baseline = legacyBaseline; - } - - // Migrate session costs - const legacySessions = this.context.globalState.get(SESSION_COSTS_KEY, []); - if (Array.isArray(legacySessions) && legacySessions.length > 0) { - const targetSess = this.storageKey(SESSION_COSTS_KEY); - this.context.globalState.update(targetSess, legacySessions); - this.context.globalState.update(SESSION_COSTS_KEY, []); - for (const s of legacySessions) { - if (typeof s.sessionId === "string" && typeof s.cost === "number") { - this.sessionCosts.set(s.sessionId, s); - } - } - } - - this.persist(); - this.persistBaseline(); - } - - /** Record a completed Go request. externalCost is from resolved metadata if available. */ - record(summary: TransportRequestSummary, externalCost?: ModelCost): void { - const displayNameLower = summary.providerDisplayName.toLowerCase(); - if (!displayNameLower.includes("go")) { - this.log?.(`[go-tracker] SKIP: providerDisplayName "${summary.providerDisplayName}" does not contain "go"`); - return; - } - - const prompt = summary.promptTokens ?? 0; - const completion = summary.completionTokens ?? 0; - const cached = summary.cachedTokens ?? 0; - - if (prompt + completion === 0) { - this.log?.(`[go-tracker] SKIP: zero tokens (prompt=${String(prompt)} completion=${String(completion)}) for model=${summary.modelId}`); - return; - } - - const cost = estimateCost(summary.modelId, prompt, completion, cached, externalCost, this.costResolver); - // VS Code session cost reads usage.copilotCredits (1 credit = $0.01). - // Compute from USD cost so the session info popover shows accurate totals. - const copilotCredits = cost * 100; - - this.log?.( - `[go-tracker] RECORD: model=${summary.modelId} prompt=${String(prompt)} completion=${String(completion)} cached=${String(cached)} cost=$${cost.toFixed(6)} credits=${copilotCredits.toFixed(4)}`, - ); - - this.entries.push({ - timestamp: Date.now(), - modelId: summary.modelId, - cost, - promptTokens: prompt, - completionTokens: completion, - cachedTokens: cached, - sessionId: summary.sessionId, - copilotCredits, - }); - this.markEverTracked(); - - // Accumulate per-session cost - if (summary.sessionId) { - const existing = this.sessionCosts.get(summary.sessionId); - if (existing) { - existing.cost += cost; - existing.requests++; - existing.promptTokens += prompt; - existing.completionTokens += completion; - existing.lastActivity = Date.now(); - } else { - this.sessionCosts.set(summary.sessionId, { - sessionId: summary.sessionId, - cost, - requests: 1, - promptTokens: prompt, - completionTokens: completion, - lastActivity: Date.now(), - }); - } - this.pruneSessions(); - } - - this.prune(); - this.persist(); - } - - getSummary(): UsageSummary { - const nowMs = Date.now(); - const clamp = (v: number, limit: number) => Math.round(Math.min(100, (v / limit) * 100) * 10) / 10; - - // The CLI database is DEVICE-level usage (it has no per-key column), so - // it is safe for the device rows (Today / Yesterday / Codebase). The - // subscription METERS must stay account-scoped: the legacy (un-namespaced) - // tracker derives them from the CLI rows, while per-profile trackers - // derive them from their own tracked entries (the server-accurate meters - // from syncServerUsage overlay them either way). - const isPerProfile = this.storageKeySuffix.length > 0; - const sqliteRows = readOpenCodeHistory(); - if (!isPerProfile && sqliteRows) { - return this.serverUsage - ? mergeServerUsage(this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp), this.serverUsage, GO_LIMITS) - : this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp); - } - - // Per-profile, or no CLI history available: meters from tracked entries, - // device rows enriched with the CLI history when it exists. - const base = this.buildSummaryFromTracked(nowMs, clamp); - if (!sqliteRows) { - return this.serverUsage ? mergeServerUsage(base, this.serverUsage, GO_LIMITS) : base; - } - const dayMs = this.dayStartMs(nowMs); - const enriched: UsageSummary = { - ...base, - today: this.dailyUsage(sqliteRows, dayMs), - yesterday: this.dailyUsage(sqliteRows, dayMs - 24 * 60 * 60 * 1000), - codebase: this.codebaseUsage(sqliteRows), - hasData: base.hasData || sqliteRows.length > 0, - sqliteAvailable: true, - }; - return this.serverUsage ? mergeServerUsage(enriched, this.serverUsage, GO_LIMITS) : enriched; - } - - private dayStartMs(nowMs: number): number { - return this.options.resolveDayBoundary?.() === "local" ? startOfLocalDay(nowMs) : startOfUtcDay(nowMs); - } - - private todayYesterdaySource(): UsageTodayYesterdaySource { - return this.options.resolveTodayYesterdaySource?.() ?? "auto"; - } - - /** - * Merge the OpenCode CLI history rows and the extension-tracked entries for - * one day window into a single total. The CLI DB records terminal usage and - * the extension records VS Code usage — the two never overlap, so summing - * them gives the user's real combined daily usage. - */ - private dailyUsage(rows: HistoryRow[], dayStartMs: number): UsageDaily { - return sumDailyUsage(rows, this.entries, dayStartMs, this.todayYesterdaySource()); - } - - /** - * Time-series data for the usage panel: per-day totals and per-model - * per-day rows over the last `days` days. - */ - getUsageSeries(days: number): UsageSeries { - const nowMs = Date.now(); - const rows = readOpenCodeHistory() ?? []; - return buildUsageSeries(rows, this.entries, days, this.dayStartMs(nowMs), this.todayYesterdaySource()); - } - - /** - * All-time usage in the CURRENT workspace, derived from the OpenCode CLI - * history (`path.cwd` of each session's messages). "Forever" by default — - * the window is controlled by `resolveCodebaseWindowDays` (0 = all history). - */ - private codebaseUsage(rows: HistoryRow[]): UsageDaily { - const folders = this.options.resolveWorkspaceFolders?.() ?? []; - const windowDays = Math.max(0, this.options.resolveCodebaseWindowDays?.() ?? 0); - const cutoffMs = windowDays > 0 ? Date.now() - windowDays * 24 * 60 * 60 * 1000 : 0; - - let cost = 0; - let requests = 0; - let tokens = 0; - for (const row of rows) { - if (cutoffMs > 0 && row.createdMs < cutoffMs) continue; - if (!isCwdInWorkspace(row.cwd, folders)) continue; - cost += row.cost; - requests += 1; - tokens += row.tokensTotal; - } - return { cost, requests, tokens }; - } - - /** - * Fetch server-accurate account-wide usage for this profile's key and - * cache it for {@link GO_USAGE_SYNC_TTL_MS}. Safe to call on every - * request/status-bar refresh: the TTL guard makes it a no-op while a - * fresh snapshot exists. Failures keep the previous snapshot (stale - * beats nothing) and the local estimates remain the fallback. - * - * @returns true when a new snapshot was fetched. - */ - async syncServerUsage(apiKey: string): Promise { - // Dedupe concurrent calls for the same key (startup + status-bar refresh - // can fire at the same moment) — a single in-flight fetch is enough. - if (this.syncInFlight && this.syncInFlight.apiKey === apiKey) { - return this.syncInFlight.promise; - } - const promise = this.performServerUsageSync(apiKey); - this.syncInFlight = { apiKey, promise }; - try { - return await promise; - } finally { - if (this.syncInFlight.promise === promise) { - this.syncInFlight = undefined; - } - } - } - - private async performServerUsageSync(apiKey: string): Promise { - const now = Date.now(); - if (this.serverUsageFetchedAt > 0 && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { - return false; - } - const result = await fetchGoUsage(apiKey); - // Pace retries after failures too — an invalid key or unreachable - // endpoint must not hammer the API on every request. - this.serverUsageFetchedAt = Date.now(); - if (!result.ok) { - this.log?.(`[go-usage] Server usage sync skipped (${result.reason}); keeping local estimates.`); - return false; - } - this.serverUsage = result.data; - // Persist so the next window start can render the meters instantly. - void this.context.globalState.update(this.storageKey(GO_SERVER_USAGE_KEY), result.data); - this.log?.("[go-usage] Server usage synced from /zen/go/v1/usage."); - return true; - } - - /** Build summary from SQLite, enriched with merged today/yesterday + codebase totals. */ - private buildSqliteEnrichedSummary(nowMs: number, rows: HistoryRow[], clamp: (v: number, limit: number) => number): UsageSummary { - const base = this.buildSummaryFromRows(nowMs, rows, clamp); - - // Today/Yesterday merge the CLI history (cost + tokens + requests) with - // the extension's own tracked requests — the two never overlap, so the - // sum is the user's real combined usage for the day. - const dayMs = this.dayStartMs(nowMs); - const yesterdayMs = dayMs - 24 * 60 * 60 * 1000; - const today = this.dailyUsage(rows, dayMs); - const yesterday = this.dailyUsage(rows, yesterdayMs); - - // Apply baselines on top of SQLite costs. - const activeBaselineSession = this.getActiveBaselineAmount("session", nowMs); - const activeBaselineWeekly = this.getActiveBaselineAmount("weekly", nowMs); - const activeBaselineMonthly = this.getActiveBaselineAmount("monthly", nowMs); - - return { - session: { - ...base.session, - spent: Math.round((base.session.spent + activeBaselineSession) * 10000) / 10000, - percent: clamp(base.session.spent + activeBaselineSession, GO_LIMITS.session), - }, - weekly: { - ...base.weekly, - spent: Math.round((base.weekly.spent + activeBaselineWeekly) * 10000) / 10000, - percent: clamp(base.weekly.spent + activeBaselineWeekly, GO_LIMITS.weekly), - }, - monthly: { - ...base.monthly, - spent: Math.round((base.monthly.spent + activeBaselineMonthly) * 10000) / 10000, - percent: clamp(base.monthly.spent + activeBaselineMonthly, GO_LIMITS.monthly), - }, - today, - yesterday, - codebase: this.codebaseUsage(rows), - hasData: true, - sqliteAvailable: true, - }; - } - - /** Build summary from opencode.db rows (enrichment data from CLI history) */ - private buildSummaryFromRows(nowMs: number, rows: HistoryRow[], clamp: (v: number, limit: number) => number): UsageSummary { - const dayMs = startOfUtcDay(nowMs); - const yesterdayMs = dayMs - 24 * 60 * 60 * 1000; - const weekMs = startOfUtcWeek(nowMs); - const sessionStart = nowMs - FIVE_HOURS_MS; - const earliest = rows.length > 0 ? Math.min(...rows.map((r) => r.createdMs)) : null; - const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, this.baseline, earliest); - const weekEnd = weekMs + WEEK_MS; - - let sessionCost = 0, - weeklyCost = 0, - monthlyCost = 0; - let todayCost = 0, - todayReq = 0; - let yestCost = 0, - yestReq = 0; - - for (const r of rows) { - if (r.createdMs >= sessionStart && r.createdMs <= nowMs) sessionCost += r.cost; - if (r.createdMs >= weekMs && r.createdMs <= nowMs) weeklyCost += r.cost; - if (r.createdMs >= monthStartMs && r.createdMs < monthEndMs) monthlyCost += r.cost; - if (r.createdMs >= dayMs) { - todayCost += r.cost; - todayReq += 1; - } else if (r.createdMs >= yesterdayMs) { - yestCost += r.cost; - yestReq += 1; - } - } - - // Rolling 5h reset: oldest entry in window + 5h - let oldest: number | null = null; - for (const r of rows) { - if (r.createdMs >= sessionStart && r.createdMs < nowMs) { - if (oldest === null || r.createdMs < oldest) oldest = r.createdMs; - } - } - - // If a monthly baseline exists and is active, use its expiresAt for resetsAt. - const monthlyResetsAt = this.baseline.monthly ? new Date(this.baseline.monthly.expiresAt) : new Date(monthEndMs); - - return { - session: { - spent: Math.round(sessionCost * 10000) / 10000, - limit: GO_LIMITS.session, - percent: clamp(sessionCost, GO_LIMITS.session), - resetsAt: new Date((oldest ?? nowMs) + FIVE_HOURS_MS), - }, - weekly: { - spent: Math.round(weeklyCost * 10000) / 10000, - limit: GO_LIMITS.weekly, - percent: clamp(weeklyCost, GO_LIMITS.weekly), - resetsAt: new Date(weekEnd), - }, - monthly: { - spent: Math.round(monthlyCost * 10000) / 10000, - limit: GO_LIMITS.monthly, - percent: clamp(monthlyCost, GO_LIMITS.monthly), - resetsAt: monthlyResetsAt, - }, - today: { - cost: Math.round(todayCost * 10000) / 10000, - requests: todayReq, - tokens: 0, // not available from SQLite - }, - yesterday: { - cost: Math.round(yestCost * 10000) / 10000, - requests: yestReq, - tokens: 0, - }, - hasData: true, - sqliteAvailable: true, - codebase: { cost: 0, requests: 0, tokens: 0 }, - }; - } - - /** Check if opencode.db is readable and has Go history */ - get hasSQLiteData(): boolean { - const rows = readOpenCodeHistory(); - return rows !== null && rows.length > 0; - } - - /** Build summary from extension-tracked entries (fallback when opencode.db unavailable) */ - private buildSummaryFromTracked(nowMs: number, clamp: (v: number, limit: number) => number): UsageSummary { - const dayMs = this.dayStartMs(nowMs); - const yesterdayMs = dayMs - 24 * 60 * 60 * 1000; - const weekMs = startOfUtcWeek(nowMs); - const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, this.baseline); - const sessionStart = nowMs - FIVE_HOURS_MS; - - let trackedSessionCost = 0, - trackedWeeklyCost = 0, - trackedMonthlyCost = 0; - let todayCost = 0, - todayReq = 0, - todayTokens = 0; - let yestCost = 0, - yestReq = 0, - yestTokens = 0; - - for (const e of this.entries) { - if (e.timestamp >= sessionStart && e.timestamp <= nowMs) trackedSessionCost += e.cost; - if (e.timestamp >= weekMs && e.timestamp <= nowMs) trackedWeeklyCost += e.cost; - if (e.timestamp >= monthStartMs && e.timestamp < monthEndMs) trackedMonthlyCost += e.cost; - if (e.timestamp >= dayMs) { - todayCost += e.cost; - todayReq += 1; - todayTokens += e.promptTokens + e.completionTokens; - } else if (e.timestamp >= yesterdayMs) { - yestCost += e.cost; - yestReq += 1; - yestTokens += e.promptTokens + e.completionTokens; - } - } - - const activeBaselineSession = this.getActiveBaselineAmount("session", nowMs); - const activeBaselineWeekly = this.getActiveBaselineAmount("weekly", nowMs); - const activeBaselineMonthly = this.getActiveBaselineAmount("monthly", nowMs); - - const sessionCost = trackedSessionCost + activeBaselineSession; - const weeklyCost = trackedWeeklyCost + activeBaselineWeekly; - const monthlyCost = trackedMonthlyCost + activeBaselineMonthly; - - const weekEnd = weekMs + WEEK_MS; - - // If a monthly baseline exists and is active, use its expiresAt for resetsAt - // instead of the anchor-based calculation (which ignores manual targets). - const monthlyResetsAt = this.baseline.monthly ? new Date(this.baseline.monthly.expiresAt) : new Date(monthEndMs); - - return { - session: { - spent: Math.round(sessionCost * 10000) / 10000, - limit: GO_LIMITS.session, - percent: clamp(sessionCost, GO_LIMITS.session), - resetsAt: nextSessionReset(this.entries, nowMs), - }, - weekly: { - spent: Math.round(weeklyCost * 10000) / 10000, - limit: GO_LIMITS.weekly, - percent: clamp(weeklyCost, GO_LIMITS.weekly), - resetsAt: new Date(weekEnd), - }, - monthly: { - spent: Math.round(monthlyCost * 10000) / 10000, - limit: GO_LIMITS.monthly, - percent: clamp(monthlyCost, GO_LIMITS.monthly), - resetsAt: monthlyResetsAt, - }, - today: { - cost: Math.round(todayCost * 10000) / 10000, - requests: todayReq, - tokens: todayTokens, - }, - yesterday: { - cost: Math.round(yestCost * 10000) / 10000, - requests: yestReq, - tokens: yestTokens, - }, - // Without the CLI history there is no per-directory attribution, so the - // codebase total falls back to everything this extension has tracked - // (it only ever runs inside the current workspace). - codebase: { - cost: Math.round(this.entries.reduce((total, e) => total + e.cost, 0) * 10000) / 10000, - requests: this.entries.length, - tokens: this.entries.reduce((total, e) => total + e.promptTokens + e.completionTokens, 0), - }, - hasData: this.entries.length > 0 || this.everTracked, - sqliteAvailable: false, - }; - } - - setManualSpentTargets(targets: UsageBaselineTargets): void { - const nowMs = Date.now(); - - // ── Monthly ─────────────────────────────────────────────────────────── - // When namespaced, skip SQLite — it has no key column and would mix - // quota from all accounts. - const isPerProfile = this.storageKeySuffix.length > 0; - const sqliteRows = isPerProfile ? null : readOpenCodeHistory(); - let sqliteMonthlyCost = 0; - if (sqliteRows && sqliteRows.length > 0) { - const earliest = Math.min(...sqliteRows.map((r) => r.createdMs)); - // Build a temporary baseline to let buildMonthlyWindow find the anchor - const tempBaseline: UsageBaseline = { ...this.baseline }; - if (targets.monthlyAnchorDay && targets.monthlyAnchorDay >= 1 && targets.monthlyAnchorDay <= 31) { - tempBaseline.monthly = { - ...(tempBaseline.monthly ?? { amount: 0, expiresAt: 0 }), - anchorDay: targets.monthlyAnchorDay, - anchorHour: targets.monthlyAnchorHour ?? 0, - }; - } - const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, tempBaseline, earliest); - for (const r of sqliteRows) { - if (r.createdMs >= monthStartMs && r.createdMs < monthEndMs) { - sqliteMonthlyCost += r.cost; - } - } - } - - const currentBaselineMonthly = this.getActiveBaselineAmount("monthly", nowMs); - // trackedMonthly = what SQLite shows for the target window + tracked entries baseline adjustment - // When no SQLite, fall back to tracked entries for the target window - let trackedMonthly = sqliteMonthlyCost; - if (!sqliteRows) { - // No SQLite: compute from tracked entries using the target window - const tempBaseline: UsageBaseline = { ...this.baseline }; - if (targets.monthlyAnchorDay && targets.monthlyAnchorDay >= 1 && targets.monthlyAnchorDay <= 31) { - tempBaseline.monthly = { - ...(tempBaseline.monthly ?? { amount: 0, expiresAt: 0 }), - anchorDay: targets.monthlyAnchorDay, - anchorHour: targets.monthlyAnchorHour ?? 0, - }; - } - const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, tempBaseline); - for (const e of this.entries) { - if (e.timestamp >= monthStartMs && e.timestamp < monthEndMs) { - trackedMonthly += e.cost; - } - } - } - // If SQLite had no rows (empty array), use summary fallback - if (sqliteRows && sqliteRows.length === 0) { - const summary = this.getSummary(); - trackedMonthly = Math.max(0, summary.monthly.spent - currentBaselineMonthly); - } - // For SQLite path, subtract the current baseline so we don't double-count - if (sqliteRows && sqliteRows.length > 0) { - trackedMonthly = Math.max(0, trackedMonthly - currentBaselineMonthly); - } - - // ── Session and Weekly ──────────────────────────────────────────────── - const summary = this.getSummary(); - const currentBaselineSession = this.getActiveBaselineAmount("session", nowMs); - const currentBaselineWeekly = this.getActiveBaselineAmount("weekly", nowMs); - - const trackedSession = Math.max(0, summary.session.spent - currentBaselineSession); - const trackedWeekly = Math.max(0, summary.weekly.spent - currentBaselineWeekly); - - this.baseline.session = { - amount: targets.session - trackedSession, - expiresAt: summary.session.resetsAt.getTime(), - }; - this.baseline.weekly = { - amount: targets.weekly - trackedWeekly, - expiresAt: summary.weekly.resetsAt.getTime(), - }; - this.baseline.monthly = { - amount: targets.monthly - trackedMonthly, - expiresAt: summary.monthly.resetsAt.getTime(), - }; - - // Override monthly expiry if caller provided anchor day + hour. - if (targets.monthlyAnchorDay && targets.monthlyAnchorDay >= 1 && targets.monthlyAnchorDay <= 31) { - const hour = targets.monthlyAnchorHour ?? 0; - const now = new Date(nowMs); - let year = now.getUTCFullYear(); - let month = now.getUTCMonth(); - let candidate = Date.UTC(year, month, targets.monthlyAnchorDay, hour, 0, 0, 0); - if (candidate <= nowMs) { - // If the anchor day+hour has passed this month, next reset is next month. - month++; - if (month > 11) { - year++; - month = 0; - } - candidate = Date.UTC(year, month, targets.monthlyAnchorDay, hour, 0, 0, 0); - } - this.baseline.monthly = { - amount: this.baseline.monthly.amount, - expiresAt: candidate, - anchorDay: targets.monthlyAnchorDay, - anchorHour: hour, - }; - } - - this.persistBaseline(); - } - - clear(): void { - this.entries = []; - this.baseline = {}; - this.sessionCosts.clear(); - this.persist(); - this.persistBaseline(); - // Keep the usage card alive with zeroed values instead of falling back - // to the first-run "no data" state. - this.markEverTracked(); - } - - /** Mark (and persist) that this profile has local usage history. */ - private markEverTracked(): void { - if (this.everTracked) return; - this.everTracked = true; - void this.context.globalState.update(this.storageKey(EVER_TRACKED_KEY), true); - } - - /** Whether a server-accurate usage snapshot is currently in effect. */ - get hasServerUsage(): boolean { - return this.serverUsage !== undefined; - } - - private prune(): void { - // Tracked usage is permanent — users rely on the history for today/ - // yesterday/codebase totals, so no time-based cutoff. Only the hard - // entry cap applies. - this.entries = this.entries.slice(-MAX_LOG_ENTRIES); - } - - /** Remove idle sessions and cap total count. */ - private pruneSessions(): void { - const now = Date.now(); - const idleCutoff = now - GoUsageTracker.SESSION_IDLE_MS; - for (const [id, s] of this.sessionCosts) { - if (s.lastActivity < idleCutoff) { - this.sessionCosts.delete(id); - } - } - // If still over limit, remove oldest by lastActivity - if (this.sessionCosts.size > GoUsageTracker.MAX_SESSIONS) { - const sorted = [...this.sessionCosts.entries()].sort((a, b) => a[1].lastActivity - b[1].lastActivity); - const toRemove = sorted.length - GoUsageTracker.MAX_SESSIONS; - for (let i = 0; i < toRemove; i++) { - this.sessionCosts.delete(sorted[i][0]); - } - } - } - - /** Returns the most recent chat session's cost summary. */ - getCurrentSessionCost(): SessionCostSummary | undefined { - let latest: SessionCostSummary | undefined; - for (const s of this.sessionCosts.values()) { - if (!latest || s.lastActivity > latest.lastActivity) { - latest = s; - } - } - return latest; - } - - /** Returns up to `limit` most recent session cost summaries, ordered by last activity (newest first). */ - getRecentSessionCosts(limit = 5): SessionCostSummary[] { - return [...this.sessionCosts.values()].sort((a, b) => b.lastActivity - a.lastActivity).slice(0, limit); - } - - private persist(): void { - void this.context.globalState.update(this.storageKey(STORAGE_KEY), this.entries); - void this.context.globalState.update(this.storageKey(SESSION_COSTS_KEY), [...this.sessionCosts.values()]); - } - - private persistBaseline(): void { - void this.context.globalState.update(this.storageKey(BASELINE_STORAGE_KEY), this.baseline); - } - - private getActiveBaselineAmount(period: keyof UsageBaseline, nowMs: number): number { - const entry = this.baseline[period]; - if (!entry) return 0; - if (entry.expiresAt <= nowMs) { - this.baseline[period] = undefined; - this.persistBaseline(); - return 0; - } - return entry.amount; - } - - private restore(): void { - const stored = this.context.globalState.get(this.storageKey(STORAGE_KEY), []); - if (Array.isArray(stored)) { - this.entries = stored.filter((e) => typeof e.timestamp === "number" && typeof e.cost === "number"); - } - - this.everTracked = this.context.globalState.get(this.storageKey(EVER_TRACKED_KEY), this.entries.length > 0); - - const baseline = this.context.globalState.get(this.storageKey(BASELINE_STORAGE_KEY), {}); - if (typeof baseline === "object") { - this.baseline = baseline; - } - - // Restore session costs from persistence - const storedSessions = this.context.globalState.get(this.storageKey(SESSION_COSTS_KEY), []); - if (Array.isArray(storedSessions)) { - for (const s of storedSessions) { - if (typeof s.sessionId === "string" && typeof s.cost === "number") { - this.sessionCosts.set(s.sessionId, s); - } - } - this.pruneSessions(); - } - } -} - -// ─── Formatting helpers ────────────────────────────────────────────────────── - -function progressBar(percent: number, width = 10): string { - const filled = Math.round((percent / 100) * width); - return "█".repeat(filled) + "░".repeat(width - filled); -} - -function fmtDate(d: Date): string { - return ( - d.toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - timeZone: "UTC", - }) + " UTC" - ); -} - -function percentColor(pct: number): string { - if (pct >= 90) return "⛔"; - if (pct >= 75) return "🟠"; - if (pct >= 50) return "🟡"; - return "🟢"; -} - -/** Status bar label: e.g. "Go: 27%·62%·75%" */ -export function formatGoUsageStatusBarText(summary: UsageSummary): string { - if (!summary.hasData) return "OpenCode Go"; - const s = summary.session.percent; - const w = summary.weekly.percent; - const m = summary.monthly.percent; - const warn = s >= 80 || w >= 80 || m >= 80 ? " $(warning)" : ""; - return `Go: ${String(s)}%·${String(w)}%·${String(m)}%${warn}`; -} - -/** Build Quick Pick items for the usage panel */ -export function buildUsageQuickPickItems(summary: UsageSummary, syncedFromServer = false, showRollingMeter = true): vscode.QuickPickItem[] { - const now = new Date(); - const isEmpty = !summary.hasData; - - function periodItem(icon: string, label: string, period: PeriodUsage, resetLabel: string): vscode.QuickPickItem { - const bar = progressBar(period.percent); - const spent = formatUsd(period.spent); - const limit = formatUsd(period.limit); - const resets = formatRelativeTime(period.resetsAt, now); - return { - label: `${icon} ${label}`, - description: `${bar} ${String(period.percent)}%`, - detail: `${spent} / ${limit} used · resets in ${resets} (${resetLabel})`, - alwaysShow: true, - }; - } - - const items: vscode.QuickPickItem[] = []; - - if (isEmpty) { - items.push({ - label: "$(info) Ready to track", - detail: "Send a chat message to any OpenCode Go model to start tracking usage.", - alwaysShow: true, - }); - } - - if (syncedFromServer) { - items.push({ - label: "$(cloud) Synced from opencode.ai", - detail: "Session/Weekly/Monthly meters are account-wide and server-accurate.", - alwaysShow: true, - }); - } - - // ── Period bars ────────────────────────────────────────────────────────── - items.push({ label: "Subscription Limits", kind: vscode.QuickPickItemKind.Separator }); - - if (showRollingMeter) { - items.push( - periodItem( - percentColor(summary.session.percent) + " $(clock)", - "Session (5h rolling)", - summary.session, - fmtDate(summary.session.resetsAt), - ), - ); - } - - items.push(periodItem(percentColor(summary.weekly.percent) + " $(calendar)", "Weekly", summary.weekly, fmtDate(summary.weekly.resetsAt))); - - items.push( - periodItem(percentColor(summary.monthly.percent) + " $(graph)", "Monthly", summary.monthly, fmtDate(summary.monthly.resetsAt)), - ); - - // ── Daily summary ──────────────────────────────────────────────────────── - items.push({ label: "Daily Summary", kind: vscode.QuickPickItemKind.Separator }); - - items.push({ - label: `$(history) Today`, - description: formatUsd(summary.today.cost), - detail: `${formatTokenCount(summary.today.tokens)} tokens · ${formatCount(summary.today.requests)} requests`, - alwaysShow: true, - }); - - if (summary.yesterday.requests > 0 || isEmpty) { - items.push({ - label: `$(history) Yesterday`, - description: formatUsd(summary.yesterday.cost), - detail: `${formatTokenCount(summary.yesterday.tokens)} tokens · ${formatCount(summary.yesterday.requests)} requests`, - alwaysShow: true, - }); - } - - // ── Actions ────────────────────────────────────────────────────────────── - items.push({ label: "Actions", kind: vscode.QuickPickItemKind.Separator }); - - items.push({ - label: "$(link-external) Open OpenCode console", - description: "View usage at opencode.ai", - alwaysShow: true, - _action: "openConsole", - } as vscode.QuickPickItem & { _action: string }); - - return items; -} - -export { GO_VENDOR }; +export { GO_LIMITS } from "./config"; +export { GO_VENDOR } from "./providerTypes"; +export { estimateCost, type CostResolver } from "./usage/pricing"; +export { + GoUsageTracker, + startOfLocalDay, + normalizeCwd, + isCwdInWorkspace, + type GoUsageTrackerOptions, + type UsageBaselineTargets, + type UsageLogEntry, + type SessionCostSummary, + type PeriodUsage, + type UsageSummary, +} from "./usage/tracker"; +export { + buildUsageSeries, + setHistoryReadDiagnostic, + sumDailyUsage, + type HistoryRow, + type ModelDayUsage, + type UsageDaily, + type UsageDayPoint, + type UsageSeries, +} from "./usage/history"; +export { buildUsageQuickPickItems, formatGoUsageStatusBarText } from "./usage/formatting"; diff --git a/src/streaming.ts b/src/streaming.ts index 68151d4..b33d5b0 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -28,7 +28,7 @@ import { reportUsageToContextWindowForRequest, setContextWindowOutputBufferForRequest, } from "./contextWindowHookBridge"; -import { formatUsageLogLine } from "./usage"; +import { formatUsageLogLine } from "./usage/usage"; import { parseToolInput, ToolCallAccumulator, type PendingToolCall } from "./toolCallAccumulator"; import { getErrorMessage, isRecord, sleepWithCancellation } from "./utils"; diff --git a/src/test/goUsageSync.test.ts b/src/test/goUsageSync.test.ts index 3fcf801..7e4075c 100644 --- a/src/test/goUsageSync.test.ts +++ b/src/test/goUsageSync.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { fetchGoUsage, mergeServerUsage, GO_USAGE_API_URL, type GoUsageApiResponse } from "../goUsageSync"; +import { fetchGoUsage, mergeServerUsage, GO_USAGE_API_URL, type GoUsageApiResponse } from "../usage/goUsageSync"; import type { UsageSummary } from "../goUsageTracker"; /** Mirrors GO_LIMITS — kept literal so the test never loads goUsageTracker (vscode). */ diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 84a07b9..35988cf 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -14,7 +14,7 @@ import { GO_MAX_SESSIONS, GO_SERVER_USAGE_KEY, } from "../config.js"; -import type { GoUsageApiResponse } from "../goUsageSync"; +import type { GoUsageApiResponse } from "../usage/goUsageSync"; import type { HistoryRow, UsageDaily, UsageLogEntry, UsageSummary } from "../goUsageTracker.js"; import type { UsageSeries } from "../goUsageTracker.js"; diff --git a/src/test/usageProfile.test.ts b/src/test/usageProfile.test.ts index d65a9ac..c7422cd 100644 --- a/src/test/usageProfile.test.ts +++ b/src/test/usageProfile.test.ts @@ -6,7 +6,7 @@ import fs from "node:fs"; import os from "node:os"; import type { ExtensionContext } from "vscode"; -let mod: typeof import("../usageProfile.js"); +let mod: typeof import("../usage/usageProfile.js"); function createMockContext(initial: Record = {}): ExtensionContext { const store = new Map(Object.entries(initial)); @@ -42,7 +42,7 @@ moduleResolver._resolveFilename = function (request: string, parent: unknown, .. }; before(async () => { - mod = await import("../usageProfile.js"); + mod = await import("../usage/usageProfile.js"); }); describe("keyFingerprint", () => { diff --git a/src/usage/formatting.ts b/src/usage/formatting.ts new file mode 100644 index 0000000..2bcf302 --- /dev/null +++ b/src/usage/formatting.ts @@ -0,0 +1,126 @@ +import * as vscode from "vscode"; +import { formatCount, formatTokenCount, formatUsd, formatRelativeTime } from "../utils"; +import type { PeriodUsage, UsageSummary } from "./tracker"; + +// ─── Formatting helpers ────────────────────────────────────────────────────── + +function progressBar(percent: number, width = 10): string { + const filled = Math.round((percent / 100) * width); + return "█".repeat(filled) + "░".repeat(width - filled); +} + +function fmtDate(d: Date): string { + return ( + d.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + timeZone: "UTC", + }) + " UTC" + ); +} + +function percentColor(pct: number): string { + if (pct >= 90) return "⛔"; + if (pct >= 75) return "🟠"; + if (pct >= 50) return "🟡"; + return "🟢"; +} + +/** Status bar label: e.g. "Go: 27%·62%·75%" */ +export function formatGoUsageStatusBarText(summary: UsageSummary): string { + if (!summary.hasData) return "OpenCode Go"; + const s = summary.session.percent; + const w = summary.weekly.percent; + const m = summary.monthly.percent; + const warn = s >= 80 || w >= 80 || m >= 80 ? " $(warning)" : ""; + return `Go: ${String(s)}%·${String(w)}%·${String(m)}%${warn}`; +} + +/** Build Quick Pick items for the usage panel */ +export function buildUsageQuickPickItems(summary: UsageSummary, syncedFromServer = false, showRollingMeter = true): vscode.QuickPickItem[] { + const now = new Date(); + const isEmpty = !summary.hasData; + + function periodItem(icon: string, label: string, period: PeriodUsage, resetLabel: string): vscode.QuickPickItem { + const bar = progressBar(period.percent); + const spent = formatUsd(period.spent); + const limit = formatUsd(period.limit); + const resets = formatRelativeTime(period.resetsAt, now); + return { + label: `${icon} ${label}`, + description: `${bar} ${String(period.percent)}%`, + detail: `${spent} / ${limit} used · resets in ${resets} (${resetLabel})`, + alwaysShow: true, + }; + } + + const items: vscode.QuickPickItem[] = []; + + if (isEmpty) { + items.push({ + label: "$(info) Ready to track", + detail: "Send a chat message to any OpenCode Go model to start tracking usage.", + alwaysShow: true, + }); + } + + if (syncedFromServer) { + items.push({ + label: "$(cloud) Synced from opencode.ai", + detail: "Session/Weekly/Monthly meters are account-wide and server-accurate.", + alwaysShow: true, + }); + } + + // ── Period bars ────────────────────────────────────────────────────────── + items.push({ label: "Subscription Limits", kind: vscode.QuickPickItemKind.Separator }); + + if (showRollingMeter) { + items.push( + periodItem( + percentColor(summary.session.percent) + " $(clock)", + "Session (5h rolling)", + summary.session, + fmtDate(summary.session.resetsAt), + ), + ); + } + + items.push(periodItem(percentColor(summary.weekly.percent) + " $(calendar)", "Weekly", summary.weekly, fmtDate(summary.weekly.resetsAt))); + + items.push( + periodItem(percentColor(summary.monthly.percent) + " $(graph)", "Monthly", summary.monthly, fmtDate(summary.monthly.resetsAt)), + ); + + // ── Daily summary ──────────────────────────────────────────────────────── + items.push({ label: "Daily Summary", kind: vscode.QuickPickItemKind.Separator }); + + items.push({ + label: `$(history) Today`, + description: formatUsd(summary.today.cost), + detail: `${formatTokenCount(summary.today.tokens)} tokens · ${formatCount(summary.today.requests)} requests`, + alwaysShow: true, + }); + + if (summary.yesterday.requests > 0 || isEmpty) { + items.push({ + label: `$(history) Yesterday`, + description: formatUsd(summary.yesterday.cost), + detail: `${formatTokenCount(summary.yesterday.tokens)} tokens · ${formatCount(summary.yesterday.requests)} requests`, + alwaysShow: true, + }); + } + + // ── Actions ────────────────────────────────────────────────────────────── + items.push({ label: "Actions", kind: vscode.QuickPickItemKind.Separator }); + + items.push({ + label: "$(link-external) Open OpenCode console", + description: "View usage at opencode.ai", + alwaysShow: true, + _action: "openConsole", + } as vscode.QuickPickItem & { _action: string }); + + return items; +} diff --git a/src/goUsageSync.ts b/src/usage/goUsageSync.ts similarity index 96% rename from src/goUsageSync.ts rename to src/usage/goUsageSync.ts index d644a1c..2f0ed42 100644 --- a/src/goUsageSync.ts +++ b/src/usage/goUsageSync.ts @@ -1,4 +1,4 @@ -import type { UsageSummary } from "./goUsageTracker"; +import type { UsageSummary } from "./tracker"; /** * Official OpenCode Go usage endpoint (upstream anomalyco/opencode#16513, @@ -13,9 +13,9 @@ import type { UsageSummary } from "./goUsageTracker"; * where `percent` is an integer 0–100 computed server-side and `resetsAt` is * an ISO timestamp. */ -import { GO_USAGE_API_URL, GO_USAGE_FETCH_TIMEOUT_MS } from "./config"; +import { GO_USAGE_API_URL, GO_USAGE_FETCH_TIMEOUT_MS } from "../config"; -export { GO_USAGE_API_URL, GO_USAGE_SYNC_TTL_MS, GO_USAGE_FETCH_TIMEOUT_MS } from "./config"; +export { GO_USAGE_API_URL, GO_USAGE_SYNC_TTL_MS, GO_USAGE_FETCH_TIMEOUT_MS } from "../config"; export type GoUsagePeriodStatus = "ok" | "rate-limited"; diff --git a/src/usage/history.ts b/src/usage/history.ts new file mode 100644 index 0000000..ef617cd --- /dev/null +++ b/src/usage/history.ts @@ -0,0 +1,373 @@ +import * as path from "path"; +import * as os from "os"; +import * as fs from "fs"; +import { execFileSync } from "child_process"; +import type { UsageTodayYesterdaySource } from "../config"; +import { getErrorMessage } from "../utils"; +import type { UsageLogEntry } from "./tracker"; + +// ─── OpenCode SQLite history reader (same source as OpenUsage) ─────────────── +// Reads from ~/.local/share/opencode/opencode.db +// SQL from https://github.com/robinebers/openusage/plugins/opencode-go/plugin.js + +const OPENCODE_DB_PATH = path.join(os.homedir(), ".local", "share", "opencode", "opencode.db"); + +const HISTORY_ROWS_SQL = ` + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, + CAST(json_extract(data, '$.cost') AS REAL) AS cost, + CAST(json_extract(data, '$.tokens.input') AS INTEGER) AS tokensInput, + CAST(json_extract(data, '$.tokens.output') AS INTEGER) AS tokensOutput, + CAST(json_extract(data, '$.tokens.reasoning') AS INTEGER) AS tokensReasoning, + CAST(json_extract(data, '$.tokens.cache.read') AS INTEGER) AS tokensCacheRead, + json_extract(data, '$.path.cwd') AS cwd, + json_extract(data, '$.modelID') AS modelId + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') +`; + +export interface HistoryRow { + createdMs: number; + cost: number; + tokensInput: number; + tokensOutput: number; + tokensReasoning: number; + tokensCacheRead: number; + /** Working directory of the session the message belongs to (OpenCode CLI data). */ + cwd?: string; + /** Model that produced the message (OpenCode CLI data). */ + modelId?: string; + /** + * Total tokens for the message: input + output + reasoning + cache.read. + * The CLI's `tokens.input` EXCLUDES cached tokens — the authoritative + * `tokens.total` matches input + output + reasoning + cache.read — so this + * sum is what any "tokens used" display must count (parity with the + * extension's own promptTokens, which include cached tokens). + */ + tokensTotal: number; +} + +/** Non-negative finite integer (tokens can legitimately be 0). */ +function positiveNumberish(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; +} + +/** + * Sum one day window across the OpenCode CLI history rows and the extension's + * tracked entries. The CLI records terminal usage and the extension records + * VS Code usage — they never overlap, so the sum is the user's real combined + * usage for the window. `source` selects which inputs participate. + */ +export function sumDailyUsage( + rows: HistoryRow[], + entries: UsageLogEntry[], + dayStartMs: number, + source: UsageTodayYesterdaySource = "auto", +): UsageDaily { + let cost = 0; + let requests = 0; + let tokens = 0; + + if (source !== "extension") { + for (const row of rows) { + if (row.createdMs < dayStartMs) continue; + cost += row.cost; + requests += 1; + tokens += row.tokensTotal; + } + } + + if (source !== "cli") { + for (const entry of entries) { + if (entry.timestamp < dayStartMs) continue; + cost += entry.cost; + requests += 1; + tokens += entry.promptTokens + entry.completionTokens; + } + } + + return { cost, requests, tokens }; +} + +/** Per-day / per-workspace usage totals (from CLI history and/or extension tracking). */ +export interface UsageDaily { + cost: number; + requests: number; + tokens: number; +} + +/** One day bucket of the usage chart. */ +export interface UsageDayPoint { + /** Unix ms at the START of the day (UTC or local, per the day-boundary setting). */ + dayStart: number; + cost: number; + tokens: number; + requests: number; +} + +/** Per-model usage for a single day (model bar chart). */ +export interface ModelDayUsage { + model: string; + dayStart: number; + cost: number; + tokens: number; + requests: number; +} + +/** Time-series data for the usage panel charts. */ +export interface UsageSeries { + /** Daily totals, oldest → newest. */ + days: UsageDayPoint[]; + /** Per-model-per-day rows (only days with usage are present). */ + byModel: ModelDayUsage[]; +} + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** + * Bucket CLI rows + extension entries into per-day totals and per-model + * per-day rows over the last `days` days (the oldest bucket starts at + * `dayStartMs - (days - 1) * DAY_MS`). Pure so it can be unit-tested. + */ +export function buildUsageSeries( + rows: HistoryRow[], + entries: UsageLogEntry[], + days: number, + dayStartMs: number, + source: UsageTodayYesterdaySource = "auto", +): UsageSeries { + // days > 0: the last `days` days ending at dayStartMs; days <= 0: lifetime + // from the earliest recorded usage to today (aligned to the day grid). + let firstDay: number; + if (days > 0) { + firstDay = dayStartMs - (Math.max(1, Math.floor(days)) - 1) * DAY_MS; + } else { + let earliest = dayStartMs; + if (source !== "extension") { + for (const row of rows) if (row.createdMs < earliest) earliest = row.createdMs; + } + if (source !== "cli") { + for (const entry of entries) if (entry.timestamp < earliest) earliest = entry.timestamp; + } + firstDay = dayStartMs - Math.ceil((dayStartMs - earliest) / DAY_MS) * DAY_MS; + } + const bucketCount = Math.round((dayStartMs - firstDay) / DAY_MS) + 1; + const buckets: UsageDayPoint[] = Array.from({ length: bucketCount }, (_, i) => ({ + dayStart: firstDay + i * DAY_MS, + cost: 0, + tokens: 0, + requests: 0, + })); + const byModel = new Map>(); + + const add = (model: string | undefined, timestamp: number, cost: number, tokens: number): void => { + const index = Math.round((timestamp - firstDay) / DAY_MS); + if (index < 0 || index >= bucketCount) return; + const day = buckets[index]; + day.cost += cost; + day.tokens += tokens; + day.requests += 1; + + const modelName = model ?? "unknown"; + let byDay = byModel.get(modelName); + if (!byDay) { + byDay = new Map(); + byModel.set(modelName, byDay); + } + const point = byDay.get(index) ?? { model: modelName, dayStart: day.dayStart, cost: 0, tokens: 0, requests: 0 }; + point.cost += cost; + point.tokens += tokens; + point.requests += 1; + byDay.set(index, point); + }; + + if (source !== "extension") { + for (const row of rows) { + add(row.modelId, row.createdMs, row.cost, row.tokensTotal); + } + } + if (source !== "cli") { + for (const entry of entries) { + add(entry.modelId, entry.timestamp, entry.cost, entry.promptTokens + entry.completionTokens); + } + } + + return { + days: buckets, + byModel: [...byModel.entries()].flatMap(([, byDay]) => + [...byDay.entries()].sort((left, right) => left[0] - right[0]).map(([, point]) => point), + ), + }; +} + +/** + * The CLI database can be gigabytes large and spawning `sqlite3` is a + * synchronous, blocking call — but the usage UI (status bar, tooltip, panel, + * quick-pick) re-reads it on every refresh. Memoize the result for a short + * window so a burst of refreshes pays the query cost once. + */ +const HISTORY_READ_TTL_MS = 3_000; +let historyCache: { rows: HistoryRow[] | null; fetchedAt: number } | undefined; +/** Surfaces CLI-history read failures in the usage output channel. */ +let historyReadDiagnostic: ((message: string) => void) | undefined; + +/** Wire the diagnostic sink (called once per tracker, last one wins). */ +export function setHistoryReadDiagnostic(log: (message: string) => void): void { + historyReadDiagnostic = log; +} + +export function readOpenCodeHistory(): HistoryRow[] | null { + const now = Date.now(); + if (historyCache && now - historyCache.fetchedAt < HISTORY_READ_TTL_MS) { + return historyCache.rows; + } + const rows = readOpenCodeHistoryUncached(); + historyCache = { rows, fetchedAt: now }; + return rows; +} + +function readOpenCodeHistoryUncached(): HistoryRow[] | null { + if (!fs.existsSync(OPENCODE_DB_PATH)) { + historyReadDiagnostic?.(`[go-usage] CLI history: database not found at ${OPENCODE_DB_PATH}`); + return null; + } + + // The `sqlite3` binary may be missing from the extension host's PATH (it is + // often only available from the Android SDK, e.g. launched from a terminal), + // so Node's built-in reader is tried first — zero external dependencies. + const viaNode = readHistoryViaNodeSqlite(); + if (viaNode !== undefined) { + return viaNode; + } + + return readHistoryViaSqliteCli(); +} + +/** Normalize raw rows (shared by both readers). */ +function normalizeHistoryRows(rows: unknown): HistoryRow[] { + if (!Array.isArray(rows)) return []; + return rows + .filter((row): row is HistoryRow => { + if (!row || typeof row !== "object") return false; + const candidate = row as Partial; + return ( + typeof candidate.createdMs === "number" && candidate.createdMs > 0 && typeof candidate.cost === "number" && candidate.cost >= 0 + ); + }) + .map((row) => { + const tokensInput = positiveNumberish(row.tokensInput); + const tokensOutput = positiveNumberish(row.tokensOutput); + const tokensReasoning = positiveNumberish(row.tokensReasoning); + const tokensCacheRead = positiveNumberish(row.tokensCacheRead); + return { + createdMs: row.createdMs, + cost: row.cost, + tokensInput, + tokensOutput, + tokensReasoning, + tokensCacheRead, + tokensTotal: tokensInput + tokensOutput + tokensReasoning + tokensCacheRead, + cwd: typeof row.cwd === "string" && row.cwd.trim() ? row.cwd : undefined, + modelId: typeof row.modelId === "string" && row.modelId.trim() ? row.modelId : undefined, + }; + }); +} + +/** + * Read the CLI history with Node's built-in `node:sqlite` (no binary on the + * host PATH needed). Returns `undefined` when the module is unavailable on + * this host so the caller can fall back to the `sqlite3` binary. + */ +function readHistoryViaNodeSqlite(): HistoryRow[] | null | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require("node:sqlite") as { + DatabaseSync?: new ( + path: string, + options?: { readOnly?: boolean }, + ) => { + prepare(sql: string): { all(): Record[] }; + close(): void; + }; + }; + if (typeof DatabaseSync !== "function") { + return undefined; + } + // Transient busy/lock states (CLI checkpointing the WAL) resolve quickly. + for (let attempt = 0; attempt < 2; attempt++) { + try { + const db = new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true }); + try { + const rows = db.prepare(HISTORY_ROWS_SQL).all(); + return rows.length > 0 ? normalizeHistoryRows(rows) : null; + } finally { + db.close(); + } + } catch (error) { + const message = getErrorMessage(error); + if (attempt === 0) { + historyReadDiagnostic?.(`[go-usage] node:sqlite read failed (attempt 1): ${message}. Retrying…`); + } else { + historyReadDiagnostic?.(`[go-usage] node:sqlite read failed: ${message}`); + } + } + } + return null; + } catch (error) { + historyReadDiagnostic?.(`[go-usage] node:sqlite unavailable (${getErrorMessage(error)}); falling back to the sqlite3 binary.`); + return undefined; + } +} + +/** + * Candidate `sqlite3` binaries: the PATH-resolved name first, then absolute + * paths from common installs (system, Homebrew, Android SDK) — the Android + * SDK binary is what most dev machines actually have, and it is frequently + * missing from the extension host's PATH. + */ +function sqliteCliCandidates(): string[] { + const home = os.homedir(); + return [ + "sqlite3", + "/usr/bin/sqlite3", + "/usr/local/bin/sqlite3", + "/opt/homebrew/bin/sqlite3", + path.join(home, "Android", "Sdk", "platform-tools", "sqlite3"), + path.join(home, "Library", "Android", "sdk", "platform-tools", "sqlite3"), + ]; +} + +function readHistoryViaSqliteCli(): HistoryRow[] | null { + for (const binary of sqliteCliCandidates()) { + for (let attempt = 0; attempt < 2; attempt++) { + try { + const result = execFileSync(binary, ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { + timeout: 10_000, + maxBuffer: 64 * 1024 * 1024, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }); + const rows: unknown = JSON.parse(result); + return Array.isArray(rows) ? normalizeHistoryRows(rows) : null; + } catch (error) { + const message = getErrorMessage(error); + // ENOENT just means this candidate isn't present — try the next one. + if (attempt === 0 && message.includes("ENOENT")) { + break; + } + if (attempt === 0) { + historyReadDiagnostic?.(`[go-usage] sqlite3 read failed (attempt 1): ${message}. Retrying…`); + } else { + historyReadDiagnostic?.(`[go-usage] sqlite3 read failed (${binary}): ${message}`); + } + } + } + } + historyReadDiagnostic?.( + "[go-usage] CLI history unavailable: no SQLite reader found (node:sqlite missing and no sqlite3 binary on PATH).", + ); + return null; +} diff --git a/src/usage/pricing.ts b/src/usage/pricing.ts new file mode 100644 index 0000000..bc46096 --- /dev/null +++ b/src/usage/pricing.ts @@ -0,0 +1,52 @@ +import type { ModelCost } from "../metadata"; + +/** Callback to resolve live model cost from the models.dev metadata cache. */ +export type CostResolver = (modelId: string) => ModelCost | undefined; + +// ─── Go model pricing ($/1M tokens) — bundled snapshot fallback ──────────── +// This table is a static snapshot kept as a last resort. The primary source +// is the live models.dev metadata cache injected via CostResolver. + +const GO_MODEL_PRICING: Record = { + "glm-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, + "glm-5": { input: 1.0, output: 3.2, cache_read: 0.2 }, + "kimi-k2.6": { input: 0.95, output: 4.0, cache_read: 0.16 }, + "kimi-k2.5": { input: 0.6, output: 3.0, cache_read: 0.1 }, + "minimax-m3": { input: 0.6, output: 2.4, cache_read: 0.12 }, + "minimax-m2.7": { input: 0.3, output: 1.2, cache_read: 0.06 }, + "minimax-m2.5": { input: 0.3, output: 1.2, cache_read: 0.06 }, + "mimo-v2.5": { input: 0.14, output: 0.28, cache_read: 0.003 }, + "mimo-v2.5-pro": { input: 1.74, output: 3.48, cache_read: 0.015 }, + "mimo-v2-omni": { input: 0.14, output: 0.28, cache_read: 0.003 }, + "mimo-v2-pro": { input: 1.74, output: 3.48, cache_read: 0.015 }, + "qwen3.7-max": { input: 2.5, output: 7.5, cache_read: 0.5 }, + "qwen3.7-plus": { input: 0.4, output: 1.6, cache_read: 0.04 }, + "qwen3.6-plus": { input: 0.5, output: 3.0, cache_read: 0.05 }, + "qwen3.5-plus": { input: 0.2, output: 1.2, cache_read: 0.02 }, + "deepseek-v4-pro": { input: 1.74, output: 3.48, cache_read: 0.015 }, + "deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.003 }, + "hy3-preview": { input: 0.5, output: 1.5, cache_read: 0.05 }, +}; + +// ─── Cost calculation ──────────────────────────────────────────────────────── + +/** Priority: caller-provided cost > live models.dev snapshot > bundled table */ +export function estimateCost( + modelId: string, + promptTokens: number, + completionTokens: number, + cachedTokens: number, + externalCost?: ModelCost, + liveCostResolver?: CostResolver, +): number { + // Priority: caller-provided cost > live models.dev snapshot > bundled table + const pricing = externalCost ?? liveCostResolver?.(modelId) ?? GO_MODEL_PRICING[modelId]; + if (!pricing) return 0; + + const billablePrompt = Math.max(0, promptTokens - cachedTokens); + return ( + (billablePrompt * pricing.input) / 1_000_000 + + (completionTokens * pricing.output) / 1_000_000 + + (cachedTokens * (pricing.cache_read ?? pricing.input * 0.1)) / 1_000_000 + ); +} diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts new file mode 100644 index 0000000..fa4c1b2 --- /dev/null +++ b/src/usage/tracker.ts @@ -0,0 +1,985 @@ +import * as vscode from "vscode"; +import type { ModelCost } from "../metadata"; +import type { TransportRequestSummary } from "../streaming"; +import { fetchGoUsage, mergeServerUsage, GO_USAGE_SYNC_TTL_MS, type GoUsageApiResponse } from "./goUsageSync"; +import { + GO_LIMITS, + FIVE_HOURS_MS, + WEEK_MS, + GO_USAGE_LOG_KEY, + GO_USAGE_BASELINE_KEY, + GO_EVER_TRACKED_KEY, + GO_SESSION_COSTS_KEY, + GO_MAX_LOG_ENTRIES, + GO_SESSION_IDLE_MS, + GO_MAX_SESSIONS, + GO_SERVER_USAGE_KEY, + type UsageTodayYesterdaySource, +} from "../config"; +import { estimateCost, type CostResolver } from "./pricing"; +import { + buildUsageSeries, + readOpenCodeHistory, + setHistoryReadDiagnostic, + sumDailyUsage, + type HistoryRow, + type UsageDaily, + type UsageSeries, +} from "./history"; + +// ─── Constants (values centralized in ./config) ────────────────────────────── + +const STORAGE_KEY = GO_USAGE_LOG_KEY; +const BASELINE_STORAGE_KEY = GO_USAGE_BASELINE_KEY; +const EVER_TRACKED_KEY = GO_EVER_TRACKED_KEY; +const SESSION_COSTS_KEY = GO_SESSION_COSTS_KEY; +const MAX_LOG_ENTRIES = GO_MAX_LOG_ENTRIES; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface UsageLogEntry { + /** Unix timestamp ms */ + timestamp: number; + modelId: string; + /** Estimated cost in USD */ + cost: number; + promptTokens: number; + completionTokens: number; + cachedTokens: number; + /** Chat session identifier (stable hash per conversation thread). */ + sessionId?: string; + /** Credits for VS Code session cost (1 credit = $0.01). */ + copilotCredits?: number; +} + +/** Aggregated cost for a single chat session. */ +export interface SessionCostSummary { + sessionId: string; + cost: number; + requests: number; + promptTokens: number; + completionTokens: number; + lastActivity: number; +} + +export interface PeriodUsage { + spent: number; + limit: number; + percent: number; + resetsAt: Date; +} + +export interface UsageSummary { + session: PeriodUsage; + weekly: PeriodUsage; + monthly: PeriodUsage; + today: UsageDaily; + yesterday: UsageDaily; + /** All-time usage in the CURRENT workspace (from OpenCode CLI history). */ + codebase: UsageDaily; + hasData: boolean; + /** When true, cost data comes from the OpenCode CLI SQLite database + (actual billed amounts). When false, costs are estimated locally. */ + sqliteAvailable: boolean; +} + +/** + * Per-view knobs resolved live so the user can pick how usage is presented. + * All resolvers are optional — the tracker falls back to sensible defaults. + */ +export interface GoUsageTrackerOptions { + /** Absolute paths of the current VS Code workspace folders. */ + resolveWorkspaceFolders?: () => readonly string[]; + /** Source of the Today/Yesterday rows (default "auto"). */ + resolveTodayYesterdaySource?: () => UsageTodayYesterdaySource; + /** Codebase window in days; 0 = forever (default). */ + resolveCodebaseWindowDays?: () => number; + /** Day boundary for Today/Yesterday ("utc" default | "local"). */ + resolveDayBoundary?: () => "utc" | "local"; +} + +interface UsageBaselinePeriod { + amount: number; + expiresAt: number; +} + +interface UsageBaseline { + session?: UsageBaselinePeriod; + weekly?: UsageBaselinePeriod; + monthly?: UsageBaselinePeriod & { + /** The user's billing anchor day (1-31) for the monthly reset. */ + anchorDay?: number; + /** The user's billing anchor hour (0-23 UTC) for the monthly reset. */ + anchorHour?: number; + }; +} + +export interface UsageBaselineTargets { + session: number; + weekly: number; + monthly: number; + /** Day of month (1-31) when monthly counter resets. Combined with monthlyAnchorHour. */ + monthlyAnchorDay?: number; + /** Hour of day (0-23 UTC) when monthly counter resets. Combined with monthlyAnchorDay. */ + monthlyAnchorHour?: number; +} + +// ─── Time window helpers ───────────────────────────────────────────────────── + +function startOfUtcDay(nowMs: number): number { + const d = new Date(nowMs); + return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); +} + +/** Start of the LOCAL day — used when `usageDayBoundary` is set to "local". */ +export function startOfLocalDay(nowMs: number): number { + const d = new Date(nowMs); + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); +} + +/** Normalize a directory path for matching (trailing separators, Windows case). */ +export function normalizeCwd(value: string): string { + let normalized = value.replace(/[\/]+$/, ""); + if (process.platform === "win32") { + normalized = normalized.toLowerCase(); + } + return normalized; +} + +/** Whether `value` starts with `prefix` followed by a path separator. */ +function startsWithPathSegment(value: string, prefix: string): boolean { + if (!value.startsWith(prefix)) { + return false; + } + return value.length > prefix.length && (value.charAt(prefix.length) === "/" || value.charAt(prefix.length) === "\\"); +} + +/** + * Whether a CLI row's working directory belongs to the current workspace. + * Matches when the folder equals the cwd, is a parent of it (the user opened + * the repo root but the CLI ran in a subfolder), or the folder is a subfolder + * of the cwd (the user opened a subfolder of the project). + * + * Segment-boundary matching accepts both `/` and `\` so POSIX-style paths and + * native Windows paths (where the separator is `\`) both match on any host. + */ +export function isCwdInWorkspace(cwd: string | undefined, workspaceFolders: readonly string[]): boolean { + if (!cwd || workspaceFolders.length === 0) { + return false; + } + const rowCwd = normalizeCwd(cwd); + for (const folder of workspaceFolders) { + const normalized = normalizeCwd(folder); + if (rowCwd === normalized) return true; + if (startsWithPathSegment(rowCwd, normalized)) return true; + if (startsWithPathSegment(normalized, rowCwd)) return true; + } + return false; +} + +function startOfUtcWeek(nowMs: number): number { + const d = new Date(nowMs); + const offset = (d.getUTCDay() + 6) % 7; // Monday=0 + d.setUTCDate(d.getUTCDate() - offset); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); +} + +function anchoredMonthStart(nowMs: number, anchorDay: number, anchorHour: number): number { + const now = new Date(nowMs); + let year = now.getUTCFullYear(); + let month = now.getUTCMonth(); + let candidate = Date.UTC(year, month, anchorDay, anchorHour, 0, 0, 0); + if (candidate > nowMs) { + if (month === 0) { + year--; + month = 11; + } else { + month--; + } + candidate = Date.UTC(year, month, anchorDay, anchorHour, 0, 0, 0); + } + return candidate; +} + +function anchoredMonthEnd(startMs: number, anchorDay: number, anchorHour: number): number { + const d = new Date(startMs); + let year = d.getUTCFullYear(); + let month = d.getUTCMonth() + 1; + if (month > 11) { + year++; + month = 0; + } + return Date.UTC(year, month, anchorDay, anchorHour, 0, 0, 0); +} + +/** Build the monthly window: manual anchor > auto-anchor from earliest row > calendar month. */ +function buildMonthlyWindow( + nowMs: number, + baseline: UsageBaseline, + earliestMs?: number | null, +): { monthStartMs: number; monthEndMs: number } { + // Priority 1: user-configured anchor (set via "Set spent targets") + const monthly = baseline.monthly; + const monthlyAnchor = monthly?.anchorDay; + if (monthly && monthlyAnchor && monthlyAnchor >= 1 && monthlyAnchor <= 31) { + const hour = monthly.anchorHour ?? 0; + const start = anchoredMonthStart(nowMs, monthlyAnchor, hour); + const end = anchoredMonthEnd(start, monthlyAnchor, hour); + return { monthStartMs: start, monthEndMs: end }; + } + // Priority 2: auto-anchor from earliest SQLite row (actual billing start) + if (earliestMs != null) { + const d = new Date(earliestMs); + const day = d.getUTCDate(); + const hour = d.getUTCHours(); + const start = anchoredMonthStart(nowMs, day, hour); + const end = anchoredMonthEnd(start, day, hour); + return { monthStartMs: start, monthEndMs: end }; + } + // Fallback: calendar month + const now = new Date(nowMs); + return { + monthStartMs: Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), + monthEndMs: Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1), + }; +} + +/** Rolling reset: oldest entry in the current 5h window + 5h */ +function nextSessionReset(entries: UsageLogEntry[], nowMs: number): Date { + const windowStart = nowMs - FIVE_HOURS_MS; + let oldest: number | null = null; + for (const e of entries) { + if (e.timestamp >= windowStart && e.timestamp < nowMs) { + if (oldest === null || e.timestamp < oldest) oldest = e.timestamp; + } + } + return new Date((oldest ?? nowMs) + FIVE_HOURS_MS); +} + +// ─── Exported tracker class ────────────────────────────────────────────────── + +export class GoUsageTracker { + private entries: UsageLogEntry[] = []; + /** + * Whether this profile has ever recorded (or had cleared) local usage. + * Kept true after a reset so the usage card shows zeroed local values + * instead of collapsing into the first-run "no data" state. + */ + private everTracked = false; + private baseline: UsageBaseline = {}; + private readonly log?: (msg: string) => void; + private costResolver?: CostResolver; + /** Per-chat-session cost accumulator. Key = sessionId. */ + private sessionCosts = new Map(); + /** Latest server-accurate usage snapshot (account-wide meters). */ + private serverUsage: GoUsageApiResponse | undefined; + /** Unix ms of the last successful {@link syncServerUsage} fetch. */ + private serverUsageFetchedAt = 0; + /** In-flight sync promise per key — prevents duplicate concurrent fetches. */ + private syncInFlight: { apiKey: string; promise: Promise } | undefined; + private static readonly SESSION_IDLE_MS = GO_SESSION_IDLE_MS; + private static readonly MAX_SESSIONS = GO_MAX_SESSIONS; + + constructor( + private readonly context: vscode.ExtensionContext, + log?: (msg: string) => void, + costResolver?: CostResolver, + /** + * Per-profile storage suffix. When set, storage keys are namespaced + * so multiple Go accounts can coexist. Empty string = legacy mode + * (single account, shared key). + */ + private readonly storageKeySuffix = "", + private readonly options: GoUsageTrackerOptions = {}, + ) { + this.log = log; + this.costResolver = costResolver; + if (log) { + setHistoryReadDiagnostic(log); + } + this.restore(); + // Fast startup: show the last successful server snapshot immediately + // instead of 0s until the TTL-guarded refetch lands. `serverUsageFetchedAt` + // stays 0, so the background sync still refreshes right away. + this.serverUsage = this.context.globalState.get(this.storageKey(GO_SERVER_USAGE_KEY)); + } + + private storageKey(base: string): string { + return this.storageKeySuffix ? `${base}.${this.storageKeySuffix}` : base; + } + + /** + * Copy all data from the singleton legacy keys (without suffix) into + * this profile's namespaced storage. Called once during the first + * activation after a single-account user upgrades to multi-account. + */ + migrateFromSingleton(): void { + if (!this.storageKeySuffix) return; // i am the singleton + const hasLegacyEntries = this.context.globalState.get(STORAGE_KEY, []).length > 0; + if (!hasLegacyEntries) return; + + this.log?.("[go-tracker] migrating legacy singleton data into profile"); + + // Migrate entries + const legacyEntries = this.context.globalState.get(STORAGE_KEY, []); + if (Array.isArray(legacyEntries) && legacyEntries.length > 0) { + const targetKey = this.storageKey(STORAGE_KEY); + this.context.globalState.update(targetKey, legacyEntries); + this.context.globalState.update(STORAGE_KEY, []); + this.entries = legacyEntries.filter((e) => typeof e.timestamp === "number" && typeof e.cost === "number"); + } + + // Migrate baseline + const legacyBaseline = this.context.globalState.get(BASELINE_STORAGE_KEY, {}); + if (Object.keys(legacyBaseline).length > 0) { + const targetBase = this.storageKey(BASELINE_STORAGE_KEY); + this.context.globalState.update(targetBase, legacyBaseline); + this.context.globalState.update(BASELINE_STORAGE_KEY, {}); + this.baseline = legacyBaseline; + } + + // Migrate session costs + const legacySessions = this.context.globalState.get(SESSION_COSTS_KEY, []); + if (Array.isArray(legacySessions) && legacySessions.length > 0) { + const targetSess = this.storageKey(SESSION_COSTS_KEY); + this.context.globalState.update(targetSess, legacySessions); + this.context.globalState.update(SESSION_COSTS_KEY, []); + for (const s of legacySessions) { + if (typeof s.sessionId === "string" && typeof s.cost === "number") { + this.sessionCosts.set(s.sessionId, s); + } + } + } + + this.persist(); + this.persistBaseline(); + } + + /** Record a completed Go request. externalCost is from resolved metadata if available. */ + record(summary: TransportRequestSummary, externalCost?: ModelCost): void { + const displayNameLower = summary.providerDisplayName.toLowerCase(); + if (!displayNameLower.includes("go")) { + this.log?.(`[go-tracker] SKIP: providerDisplayName "${summary.providerDisplayName}" does not contain "go"`); + return; + } + + const prompt = summary.promptTokens ?? 0; + const completion = summary.completionTokens ?? 0; + const cached = summary.cachedTokens ?? 0; + + if (prompt + completion === 0) { + this.log?.(`[go-tracker] SKIP: zero tokens (prompt=${String(prompt)} completion=${String(completion)}) for model=${summary.modelId}`); + return; + } + + const cost = estimateCost(summary.modelId, prompt, completion, cached, externalCost, this.costResolver); + // VS Code session cost reads usage.copilotCredits (1 credit = $0.01). + // Compute from USD cost so the session info popover shows accurate totals. + const copilotCredits = cost * 100; + + this.log?.( + `[go-tracker] RECORD: model=${summary.modelId} prompt=${String(prompt)} completion=${String(completion)} cached=${String(cached)} cost=$${cost.toFixed(6)} credits=${copilotCredits.toFixed(4)}`, + ); + + this.entries.push({ + timestamp: Date.now(), + modelId: summary.modelId, + cost, + promptTokens: prompt, + completionTokens: completion, + cachedTokens: cached, + sessionId: summary.sessionId, + copilotCredits, + }); + this.markEverTracked(); + + // Accumulate per-session cost + if (summary.sessionId) { + const existing = this.sessionCosts.get(summary.sessionId); + if (existing) { + existing.cost += cost; + existing.requests++; + existing.promptTokens += prompt; + existing.completionTokens += completion; + existing.lastActivity = Date.now(); + } else { + this.sessionCosts.set(summary.sessionId, { + sessionId: summary.sessionId, + cost, + requests: 1, + promptTokens: prompt, + completionTokens: completion, + lastActivity: Date.now(), + }); + } + this.pruneSessions(); + } + + this.prune(); + this.persist(); + } + + getSummary(): UsageSummary { + const nowMs = Date.now(); + const clamp = (v: number, limit: number) => Math.round(Math.min(100, (v / limit) * 100) * 10) / 10; + + // The CLI database is DEVICE-level usage (it has no per-key column), so + // it is safe for the device rows (Today / Yesterday / Codebase). The + // subscription METERS must stay account-scoped: the legacy (un-namespaced) + // tracker derives them from the CLI rows, while per-profile trackers + // derive them from their own tracked entries (the server-accurate meters + // from syncServerUsage overlay them either way). + const isPerProfile = this.storageKeySuffix.length > 0; + const sqliteRows = readOpenCodeHistory(); + if (!isPerProfile && sqliteRows) { + return this.serverUsage + ? mergeServerUsage(this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp), this.serverUsage, GO_LIMITS) + : this.buildSqliteEnrichedSummary(nowMs, sqliteRows, clamp); + } + + // Per-profile, or no CLI history available: meters from tracked entries, + // device rows enriched with the CLI history when it exists. + const base = this.buildSummaryFromTracked(nowMs, clamp); + if (!sqliteRows) { + return this.serverUsage ? mergeServerUsage(base, this.serverUsage, GO_LIMITS) : base; + } + const dayMs = this.dayStartMs(nowMs); + const enriched: UsageSummary = { + ...base, + today: this.dailyUsage(sqliteRows, dayMs), + yesterday: this.dailyUsage(sqliteRows, dayMs - 24 * 60 * 60 * 1000), + codebase: this.codebaseUsage(sqliteRows), + hasData: base.hasData || sqliteRows.length > 0, + sqliteAvailable: true, + }; + return this.serverUsage ? mergeServerUsage(enriched, this.serverUsage, GO_LIMITS) : enriched; + } + + private dayStartMs(nowMs: number): number { + return this.options.resolveDayBoundary?.() === "local" ? startOfLocalDay(nowMs) : startOfUtcDay(nowMs); + } + + private todayYesterdaySource(): UsageTodayYesterdaySource { + return this.options.resolveTodayYesterdaySource?.() ?? "auto"; + } + + /** + * Merge the OpenCode CLI history rows and the extension-tracked entries for + * one day window into a single total. The CLI DB records terminal usage and + * the extension records VS Code usage — the two never overlap, so summing + * them gives the user's real combined daily usage. + */ + private dailyUsage(rows: HistoryRow[], dayStartMs: number): UsageDaily { + return sumDailyUsage(rows, this.entries, dayStartMs, this.todayYesterdaySource()); + } + + /** + * Time-series data for the usage panel: per-day totals and per-model + * per-day rows over the last `days` days. + */ + getUsageSeries(days: number): UsageSeries { + const nowMs = Date.now(); + const rows = readOpenCodeHistory() ?? []; + return buildUsageSeries(rows, this.entries, days, this.dayStartMs(nowMs), this.todayYesterdaySource()); + } + + /** + * All-time usage in the CURRENT workspace, derived from the OpenCode CLI + * history (`path.cwd` of each session's messages). "Forever" by default — + * the window is controlled by `resolveCodebaseWindowDays` (0 = all history). + */ + private codebaseUsage(rows: HistoryRow[]): UsageDaily { + const folders = this.options.resolveWorkspaceFolders?.() ?? []; + const windowDays = Math.max(0, this.options.resolveCodebaseWindowDays?.() ?? 0); + const cutoffMs = windowDays > 0 ? Date.now() - windowDays * 24 * 60 * 60 * 1000 : 0; + + let cost = 0; + let requests = 0; + let tokens = 0; + for (const row of rows) { + if (cutoffMs > 0 && row.createdMs < cutoffMs) continue; + if (!isCwdInWorkspace(row.cwd, folders)) continue; + cost += row.cost; + requests += 1; + tokens += row.tokensTotal; + } + return { cost, requests, tokens }; + } + + /** + * Fetch server-accurate account-wide usage for this profile's key and + * cache it for {@link GO_USAGE_SYNC_TTL_MS}. Safe to call on every + * request/status-bar refresh: the TTL guard makes it a no-op while a + * fresh snapshot exists. Failures keep the previous snapshot (stale + * beats nothing) and the local estimates remain the fallback. + * + * @returns true when a new snapshot was fetched. + */ + async syncServerUsage(apiKey: string): Promise { + // Dedupe concurrent calls for the same key (startup + status-bar refresh + // can fire at the same moment) — a single in-flight fetch is enough. + if (this.syncInFlight && this.syncInFlight.apiKey === apiKey) { + return this.syncInFlight.promise; + } + const promise = this.performServerUsageSync(apiKey); + this.syncInFlight = { apiKey, promise }; + try { + return await promise; + } finally { + if (this.syncInFlight.promise === promise) { + this.syncInFlight = undefined; + } + } + } + + private async performServerUsageSync(apiKey: string): Promise { + const now = Date.now(); + if (this.serverUsageFetchedAt > 0 && now - this.serverUsageFetchedAt < GO_USAGE_SYNC_TTL_MS) { + return false; + } + const result = await fetchGoUsage(apiKey); + // Pace retries after failures too — an invalid key or unreachable + // endpoint must not hammer the API on every request. + this.serverUsageFetchedAt = Date.now(); + if (!result.ok) { + this.log?.(`[go-usage] Server usage sync skipped (${result.reason}); keeping local estimates.`); + return false; + } + this.serverUsage = result.data; + // Persist so the next window start can render the meters instantly. + void this.context.globalState.update(this.storageKey(GO_SERVER_USAGE_KEY), result.data); + this.log?.("[go-usage] Server usage synced from /zen/go/v1/usage."); + return true; + } + + /** Build summary from SQLite, enriched with merged today/yesterday + codebase totals. */ + private buildSqliteEnrichedSummary(nowMs: number, rows: HistoryRow[], clamp: (v: number, limit: number) => number): UsageSummary { + const base = this.buildSummaryFromRows(nowMs, rows, clamp); + + // Today/Yesterday merge the CLI history (cost + tokens + requests) with + // the extension's own tracked requests — the two never overlap, so the + // sum is the user's real combined usage for the day. + const dayMs = this.dayStartMs(nowMs); + const yesterdayMs = dayMs - 24 * 60 * 60 * 1000; + const today = this.dailyUsage(rows, dayMs); + const yesterday = this.dailyUsage(rows, yesterdayMs); + + // Apply baselines on top of SQLite costs. + const activeBaselineSession = this.getActiveBaselineAmount("session", nowMs); + const activeBaselineWeekly = this.getActiveBaselineAmount("weekly", nowMs); + const activeBaselineMonthly = this.getActiveBaselineAmount("monthly", nowMs); + + return { + session: { + ...base.session, + spent: Math.round((base.session.spent + activeBaselineSession) * 10000) / 10000, + percent: clamp(base.session.spent + activeBaselineSession, GO_LIMITS.session), + }, + weekly: { + ...base.weekly, + spent: Math.round((base.weekly.spent + activeBaselineWeekly) * 10000) / 10000, + percent: clamp(base.weekly.spent + activeBaselineWeekly, GO_LIMITS.weekly), + }, + monthly: { + ...base.monthly, + spent: Math.round((base.monthly.spent + activeBaselineMonthly) * 10000) / 10000, + percent: clamp(base.monthly.spent + activeBaselineMonthly, GO_LIMITS.monthly), + }, + today, + yesterday, + codebase: this.codebaseUsage(rows), + hasData: true, + sqliteAvailable: true, + }; + } + + /** Build summary from opencode.db rows (enrichment data from CLI history) */ + private buildSummaryFromRows(nowMs: number, rows: HistoryRow[], clamp: (v: number, limit: number) => number): UsageSummary { + const dayMs = startOfUtcDay(nowMs); + const yesterdayMs = dayMs - 24 * 60 * 60 * 1000; + const weekMs = startOfUtcWeek(nowMs); + const sessionStart = nowMs - FIVE_HOURS_MS; + const earliest = rows.length > 0 ? Math.min(...rows.map((r) => r.createdMs)) : null; + const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, this.baseline, earliest); + const weekEnd = weekMs + WEEK_MS; + + let sessionCost = 0, + weeklyCost = 0, + monthlyCost = 0; + let todayCost = 0, + todayReq = 0; + let yestCost = 0, + yestReq = 0; + + for (const r of rows) { + if (r.createdMs >= sessionStart && r.createdMs <= nowMs) sessionCost += r.cost; + if (r.createdMs >= weekMs && r.createdMs <= nowMs) weeklyCost += r.cost; + if (r.createdMs >= monthStartMs && r.createdMs < monthEndMs) monthlyCost += r.cost; + if (r.createdMs >= dayMs) { + todayCost += r.cost; + todayReq += 1; + } else if (r.createdMs >= yesterdayMs) { + yestCost += r.cost; + yestReq += 1; + } + } + + // Rolling 5h reset: oldest entry in window + 5h + let oldest: number | null = null; + for (const r of rows) { + if (r.createdMs >= sessionStart && r.createdMs < nowMs) { + if (oldest === null || r.createdMs < oldest) oldest = r.createdMs; + } + } + + // If a monthly baseline exists and is active, use its expiresAt for resetsAt. + const monthlyResetsAt = this.baseline.monthly ? new Date(this.baseline.monthly.expiresAt) : new Date(monthEndMs); + + return { + session: { + spent: Math.round(sessionCost * 10000) / 10000, + limit: GO_LIMITS.session, + percent: clamp(sessionCost, GO_LIMITS.session), + resetsAt: new Date((oldest ?? nowMs) + FIVE_HOURS_MS), + }, + weekly: { + spent: Math.round(weeklyCost * 10000) / 10000, + limit: GO_LIMITS.weekly, + percent: clamp(weeklyCost, GO_LIMITS.weekly), + resetsAt: new Date(weekEnd), + }, + monthly: { + spent: Math.round(monthlyCost * 10000) / 10000, + limit: GO_LIMITS.monthly, + percent: clamp(monthlyCost, GO_LIMITS.monthly), + resetsAt: monthlyResetsAt, + }, + today: { + cost: Math.round(todayCost * 10000) / 10000, + requests: todayReq, + tokens: 0, // not available from SQLite + }, + yesterday: { + cost: Math.round(yestCost * 10000) / 10000, + requests: yestReq, + tokens: 0, + }, + hasData: true, + sqliteAvailable: true, + codebase: { cost: 0, requests: 0, tokens: 0 }, + }; + } + + /** Check if opencode.db is readable and has Go history */ + get hasSQLiteData(): boolean { + const rows = readOpenCodeHistory(); + return rows !== null && rows.length > 0; + } + + /** Build summary from extension-tracked entries (fallback when opencode.db unavailable) */ + private buildSummaryFromTracked(nowMs: number, clamp: (v: number, limit: number) => number): UsageSummary { + const dayMs = this.dayStartMs(nowMs); + const yesterdayMs = dayMs - 24 * 60 * 60 * 1000; + const weekMs = startOfUtcWeek(nowMs); + const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, this.baseline); + const sessionStart = nowMs - FIVE_HOURS_MS; + + let trackedSessionCost = 0, + trackedWeeklyCost = 0, + trackedMonthlyCost = 0; + let todayCost = 0, + todayReq = 0, + todayTokens = 0; + let yestCost = 0, + yestReq = 0, + yestTokens = 0; + + for (const e of this.entries) { + if (e.timestamp >= sessionStart && e.timestamp <= nowMs) trackedSessionCost += e.cost; + if (e.timestamp >= weekMs && e.timestamp <= nowMs) trackedWeeklyCost += e.cost; + if (e.timestamp >= monthStartMs && e.timestamp < monthEndMs) trackedMonthlyCost += e.cost; + if (e.timestamp >= dayMs) { + todayCost += e.cost; + todayReq += 1; + todayTokens += e.promptTokens + e.completionTokens; + } else if (e.timestamp >= yesterdayMs) { + yestCost += e.cost; + yestReq += 1; + yestTokens += e.promptTokens + e.completionTokens; + } + } + + const activeBaselineSession = this.getActiveBaselineAmount("session", nowMs); + const activeBaselineWeekly = this.getActiveBaselineAmount("weekly", nowMs); + const activeBaselineMonthly = this.getActiveBaselineAmount("monthly", nowMs); + + const sessionCost = trackedSessionCost + activeBaselineSession; + const weeklyCost = trackedWeeklyCost + activeBaselineWeekly; + const monthlyCost = trackedMonthlyCost + activeBaselineMonthly; + + const weekEnd = weekMs + WEEK_MS; + + // If a monthly baseline exists and is active, use its expiresAt for resetsAt + // instead of the anchor-based calculation (which ignores manual targets). + const monthlyResetsAt = this.baseline.monthly ? new Date(this.baseline.monthly.expiresAt) : new Date(monthEndMs); + + return { + session: { + spent: Math.round(sessionCost * 10000) / 10000, + limit: GO_LIMITS.session, + percent: clamp(sessionCost, GO_LIMITS.session), + resetsAt: nextSessionReset(this.entries, nowMs), + }, + weekly: { + spent: Math.round(weeklyCost * 10000) / 10000, + limit: GO_LIMITS.weekly, + percent: clamp(weeklyCost, GO_LIMITS.weekly), + resetsAt: new Date(weekEnd), + }, + monthly: { + spent: Math.round(monthlyCost * 10000) / 10000, + limit: GO_LIMITS.monthly, + percent: clamp(monthlyCost, GO_LIMITS.monthly), + resetsAt: monthlyResetsAt, + }, + today: { + cost: Math.round(todayCost * 10000) / 10000, + requests: todayReq, + tokens: todayTokens, + }, + yesterday: { + cost: Math.round(yestCost * 10000) / 10000, + requests: yestReq, + tokens: yestTokens, + }, + // Without the CLI history there is no per-directory attribution, so the + // codebase total falls back to everything this extension has tracked + // (it only ever runs inside the current workspace). + codebase: { + cost: Math.round(this.entries.reduce((total, e) => total + e.cost, 0) * 10000) / 10000, + requests: this.entries.length, + tokens: this.entries.reduce((total, e) => total + e.promptTokens + e.completionTokens, 0), + }, + hasData: this.entries.length > 0 || this.everTracked, + sqliteAvailable: false, + }; + } + + setManualSpentTargets(targets: UsageBaselineTargets): void { + const nowMs = Date.now(); + + // ── Monthly ─────────────────────────────────────────────────────────── + // When namespaced, skip SQLite — it has no key column and would mix + // quota from all accounts. + const isPerProfile = this.storageKeySuffix.length > 0; + const sqliteRows = isPerProfile ? null : readOpenCodeHistory(); + let sqliteMonthlyCost = 0; + if (sqliteRows && sqliteRows.length > 0) { + const earliest = Math.min(...sqliteRows.map((r) => r.createdMs)); + // Build a temporary baseline to let buildMonthlyWindow find the anchor + const tempBaseline: UsageBaseline = { ...this.baseline }; + if (targets.monthlyAnchorDay && targets.monthlyAnchorDay >= 1 && targets.monthlyAnchorDay <= 31) { + tempBaseline.monthly = { + ...(tempBaseline.monthly ?? { amount: 0, expiresAt: 0 }), + anchorDay: targets.monthlyAnchorDay, + anchorHour: targets.monthlyAnchorHour ?? 0, + }; + } + const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, tempBaseline, earliest); + for (const r of sqliteRows) { + if (r.createdMs >= monthStartMs && r.createdMs < monthEndMs) { + sqliteMonthlyCost += r.cost; + } + } + } + + const currentBaselineMonthly = this.getActiveBaselineAmount("monthly", nowMs); + // trackedMonthly = what SQLite shows for the target window + tracked entries baseline adjustment + // When no SQLite, fall back to tracked entries for the target window + let trackedMonthly = sqliteMonthlyCost; + if (!sqliteRows) { + // No SQLite: compute from tracked entries using the target window + const tempBaseline: UsageBaseline = { ...this.baseline }; + if (targets.monthlyAnchorDay && targets.monthlyAnchorDay >= 1 && targets.monthlyAnchorDay <= 31) { + tempBaseline.monthly = { + ...(tempBaseline.monthly ?? { amount: 0, expiresAt: 0 }), + anchorDay: targets.monthlyAnchorDay, + anchorHour: targets.monthlyAnchorHour ?? 0, + }; + } + const { monthStartMs, monthEndMs } = buildMonthlyWindow(nowMs, tempBaseline); + for (const e of this.entries) { + if (e.timestamp >= monthStartMs && e.timestamp < monthEndMs) { + trackedMonthly += e.cost; + } + } + } + // If SQLite had no rows (empty array), use summary fallback + if (sqliteRows && sqliteRows.length === 0) { + const summary = this.getSummary(); + trackedMonthly = Math.max(0, summary.monthly.spent - currentBaselineMonthly); + } + // For SQLite path, subtract the current baseline so we don't double-count + if (sqliteRows && sqliteRows.length > 0) { + trackedMonthly = Math.max(0, trackedMonthly - currentBaselineMonthly); + } + + // ── Session and Weekly ──────────────────────────────────────────────── + const summary = this.getSummary(); + const currentBaselineSession = this.getActiveBaselineAmount("session", nowMs); + const currentBaselineWeekly = this.getActiveBaselineAmount("weekly", nowMs); + + const trackedSession = Math.max(0, summary.session.spent - currentBaselineSession); + const trackedWeekly = Math.max(0, summary.weekly.spent - currentBaselineWeekly); + + this.baseline.session = { + amount: targets.session - trackedSession, + expiresAt: summary.session.resetsAt.getTime(), + }; + this.baseline.weekly = { + amount: targets.weekly - trackedWeekly, + expiresAt: summary.weekly.resetsAt.getTime(), + }; + this.baseline.monthly = { + amount: targets.monthly - trackedMonthly, + expiresAt: summary.monthly.resetsAt.getTime(), + }; + + // Override monthly expiry if caller provided anchor day + hour. + if (targets.monthlyAnchorDay && targets.monthlyAnchorDay >= 1 && targets.monthlyAnchorDay <= 31) { + const hour = targets.monthlyAnchorHour ?? 0; + const now = new Date(nowMs); + let year = now.getUTCFullYear(); + let month = now.getUTCMonth(); + let candidate = Date.UTC(year, month, targets.monthlyAnchorDay, hour, 0, 0, 0); + if (candidate <= nowMs) { + // If the anchor day+hour has passed this month, next reset is next month. + month++; + if (month > 11) { + year++; + month = 0; + } + candidate = Date.UTC(year, month, targets.monthlyAnchorDay, hour, 0, 0, 0); + } + this.baseline.monthly = { + amount: this.baseline.monthly.amount, + expiresAt: candidate, + anchorDay: targets.monthlyAnchorDay, + anchorHour: hour, + }; + } + + this.persistBaseline(); + } + + clear(): void { + this.entries = []; + this.baseline = {}; + this.sessionCosts.clear(); + this.persist(); + this.persistBaseline(); + // Keep the usage card alive with zeroed values instead of falling back + // to the first-run "no data" state. + this.markEverTracked(); + } + + /** Mark (and persist) that this profile has local usage history. */ + private markEverTracked(): void { + if (this.everTracked) return; + this.everTracked = true; + void this.context.globalState.update(this.storageKey(EVER_TRACKED_KEY), true); + } + + /** Whether a server-accurate usage snapshot is currently in effect. */ + get hasServerUsage(): boolean { + return this.serverUsage !== undefined; + } + + private prune(): void { + // Tracked usage is permanent — users rely on the history for today/ + // yesterday/codebase totals, so no time-based cutoff. Only the hard + // entry cap applies. + this.entries = this.entries.slice(-MAX_LOG_ENTRIES); + } + + /** Remove idle sessions and cap total count. */ + private pruneSessions(): void { + const now = Date.now(); + const idleCutoff = now - GoUsageTracker.SESSION_IDLE_MS; + for (const [id, s] of this.sessionCosts) { + if (s.lastActivity < idleCutoff) { + this.sessionCosts.delete(id); + } + } + // If still over limit, remove oldest by lastActivity + if (this.sessionCosts.size > GoUsageTracker.MAX_SESSIONS) { + const sorted = [...this.sessionCosts.entries()].sort((a, b) => a[1].lastActivity - b[1].lastActivity); + const toRemove = sorted.length - GoUsageTracker.MAX_SESSIONS; + for (let i = 0; i < toRemove; i++) { + this.sessionCosts.delete(sorted[i][0]); + } + } + } + + /** Returns the most recent chat session's cost summary. */ + getCurrentSessionCost(): SessionCostSummary | undefined { + let latest: SessionCostSummary | undefined; + for (const s of this.sessionCosts.values()) { + if (!latest || s.lastActivity > latest.lastActivity) { + latest = s; + } + } + return latest; + } + + /** Returns up to `limit` most recent session cost summaries, ordered by last activity (newest first). */ + getRecentSessionCosts(limit = 5): SessionCostSummary[] { + return [...this.sessionCosts.values()].sort((a, b) => b.lastActivity - a.lastActivity).slice(0, limit); + } + + private persist(): void { + void this.context.globalState.update(this.storageKey(STORAGE_KEY), this.entries); + void this.context.globalState.update(this.storageKey(SESSION_COSTS_KEY), [...this.sessionCosts.values()]); + } + + private persistBaseline(): void { + void this.context.globalState.update(this.storageKey(BASELINE_STORAGE_KEY), this.baseline); + } + + private getActiveBaselineAmount(period: keyof UsageBaseline, nowMs: number): number { + const entry = this.baseline[period]; + if (!entry) return 0; + if (entry.expiresAt <= nowMs) { + this.baseline[period] = undefined; + this.persistBaseline(); + return 0; + } + return entry.amount; + } + + private restore(): void { + const stored = this.context.globalState.get(this.storageKey(STORAGE_KEY), []); + if (Array.isArray(stored)) { + this.entries = stored.filter((e) => typeof e.timestamp === "number" && typeof e.cost === "number"); + } + + this.everTracked = this.context.globalState.get(this.storageKey(EVER_TRACKED_KEY), this.entries.length > 0); + + const baseline = this.context.globalState.get(this.storageKey(BASELINE_STORAGE_KEY), {}); + if (typeof baseline === "object") { + this.baseline = baseline; + } + + // Restore session costs from persistence + const storedSessions = this.context.globalState.get(this.storageKey(SESSION_COSTS_KEY), []); + if (Array.isArray(storedSessions)) { + for (const s of storedSessions) { + if (typeof s.sessionId === "string" && typeof s.cost === "number") { + this.sessionCosts.set(s.sessionId, s); + } + } + this.pruneSessions(); + } + } +} diff --git a/src/usage.ts b/src/usage/usage.ts similarity index 99% rename from src/usage.ts rename to src/usage/usage.ts index 20fe259..f3c3f0f 100644 --- a/src/usage.ts +++ b/src/usage/usage.ts @@ -1,3 +1,5 @@ +import { formatTokenCount } from "../utils"; + export interface UsageSnapshot { promptTokens?: number; completionTokens?: number; @@ -8,8 +10,6 @@ export interface UsageSnapshot { copilotCredits?: number; } -import { formatTokenCount } from "./utils"; - export interface ProviderUsagePayload { prompt_tokens?: number; completion_tokens?: number; diff --git a/src/usageProfile.ts b/src/usage/usageProfile.ts similarity index 96% rename from src/usageProfile.ts rename to src/usage/usageProfile.ts index 5d4a45f..a6963b3 100644 --- a/src/usageProfile.ts +++ b/src/usage/usageProfile.ts @@ -4,9 +4,9 @@ * @see issue #63 */ import * as vscode from "vscode"; -import { PROFILES_REGISTRY_KEY, ACTIVE_PROFILE_KEY, MIGRATED_KEY, LEGACY_FINGERPRINT } from "./config"; +import { PROFILES_REGISTRY_KEY, ACTIVE_PROFILE_KEY, MIGRATED_KEY, LEGACY_FINGERPRINT } from "../config"; -export { PROFILES_REGISTRY_KEY, ACTIVE_PROFILE_KEY, MIGRATED_KEY, LEGACY_SECRET_KEY, LEGACY_FINGERPRINT } from "./config"; +export { PROFILES_REGISTRY_KEY, ACTIVE_PROFILE_KEY, MIGRATED_KEY, LEGACY_SECRET_KEY, LEGACY_FINGERPRINT } from "../config"; export interface UsageProfile { fingerprint: string; From 2bf8b17f7c0e7dab0af2e24d3bacb2e4004bae2b Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 14:20:11 +0800 Subject: [PATCH 10/22] refactor(transports): split streaming.ts into src/transports/ + core/transport.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 1620-line streaming.ts god file into the transport domain: - core/transport.ts — StreamRequestOptions / TransportRequestSummary types - transports/chatCompletions.ts / responses.ts / anthropic.ts / google.ts — one entry per transport (OpenAI chat / OpenAI Responses / Anthropic Messages / Google GenerateContent) - transports/engine.ts — shared HTTP+SSE streaming engine + retry/backoff - transports/sse.ts — pure SSE data-line parser - transports/extractors.ts — Base/OpenAi/Anthropic response extractors - transports/extract.ts — non-stream extraction + pure delta helpers - transports/streamParts.ts — progress/thinking part emission - transports/thinkTags.ts — pure inline tag stripper streaming.ts is kept as a thin barrel re-exporting the historical public API. Behavior-preserving; verified with npm run test-retry (mock server). --- src/core/transport.ts | 70 ++ src/streaming.ts | 1619 +---------------------------- src/transports/anthropic.ts | 28 + src/transports/chatCompletions.ts | 64 ++ src/transports/engine.ts | 409 ++++++++ src/transports/extract.ts | 225 ++++ src/transports/extractors.ts | 556 ++++++++++ src/transports/google.ts | 31 + src/transports/responses.ts | 30 + src/transports/sse.ts | 31 + src/transports/streamParts.ts | 88 ++ src/transports/thinkTags.ts | 139 +++ src/usage/tracker.ts | 2 +- 13 files changed, 1682 insertions(+), 1610 deletions(-) create mode 100644 src/core/transport.ts create mode 100644 src/transports/anthropic.ts create mode 100644 src/transports/chatCompletions.ts create mode 100644 src/transports/engine.ts create mode 100644 src/transports/extract.ts create mode 100644 src/transports/extractors.ts create mode 100644 src/transports/google.ts create mode 100644 src/transports/responses.ts create mode 100644 src/transports/sse.ts create mode 100644 src/transports/streamParts.ts create mode 100644 src/transports/thinkTags.ts diff --git a/src/core/transport.ts b/src/core/transport.ts new file mode 100644 index 0000000..1efbb85 --- /dev/null +++ b/src/core/transport.ts @@ -0,0 +1,70 @@ +import type * as vscode from "vscode"; + +/** + * Transport contract types shared by every streaming adapter in `transports/`. + * Types only — no runtime logic (safe for pure modules to import). + */ +export interface StreamRequestOptions { + url: string; + providerDisplayName: string; + apiKey: string; + modelId: string; + body: unknown; + requestHeaders: Record; + progress: vscode.Progress; + token: vscode.CancellationToken; + output?: vscode.OutputChannel; + debugReasoning: boolean; + requestTimeoutMs: number; + streamIdleTimeoutMs: number; + contextWindowOutputBuffer?: number; + authHeaders?: Record; + onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void; + capacityLimitedModelNotes?: Record; + onTransportSummary?: (summary: TransportRequestSummary) => void; + /** + * Whether `reasoning_content` should be surfaced as visible text instead of + * a thinking part. Computed UPSTREAM by the thinking provider strategy from + * the resolved thinking config — never inferred from the body here. + * + * Currently false for every family: reasoning models emit genuine CoT in + * `reasoning_content`, so it always goes to the thinking panel. (The old + * gateway #37635 mislabel is the gateway's bug, not worked around here.) + */ + treatReasoningAsContent?: boolean; + /** + * Controls whether `...` tags inlined in the model's text + * content are stripped and accumulated as reasoning content. + * + * - "never" — pass text through unchanged + * - "auto" — strip only for models known to inline thinking tags + * (currently: minimax-m*) + * - "always" — strip for every model + */ + stripThinkTags?: "never" | "auto" | "always"; +} + +export interface TransportRequestSummary { + providerDisplayName: string; + modelId: string; + url: string; + requestId?: string; + sessionId?: string; + status?: number; + contentType?: string; + payloadBytes: number; + totalBytes: number; + totalEvents: number; + durationMs: number; + ttfbMs?: number; + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + cachedTokens?: number; + finishReason?: string; + /** Credits for VS Code session cost (1 credit = $0.01). */ + copilotCredits?: number; + rateLimitSummary?: string; + abortedReason?: "request-timeout" | "stream-idle-timeout" | "cancelled"; + errorMessage?: string; +} diff --git a/src/streaming.ts b/src/streaming.ts index b33d5b0..01bc1f5 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -1,1611 +1,12 @@ -import * as vscode from "vscode"; -import { - buildOpenCodeRequestError, - formatDuration, - formatRateLimitSummary, - OpenCodeRequestError, - readRateLimitInfo, - truncateForLog, -} from "./errors"; -import { - analyzeHttp400ForRetry, - isTransientServerError, - TRANSIENT_5XX_MAX_RETRIES, - TRANSIENT_5XX_RETRY_BASE_MS, - TRANSIENT_5XX_RETRY_JITTER_MS, -} from "./retry"; -import { - normalizeGoogleFullResponse, - normalizeGoogleStreamEvent, - normalizeResponsesFullResponse, - normalizeResponsesStreamEvent, -} from "./routing"; -import { bodyRequestsThinking } from "./thinking"; -import { createReasoningMarkerPart, createUsageDataParts } from "./chatParts"; -import { - clearContextWindowRequest, - reportProgressWithContextWindowRequest, - reportUsageToContextWindowForRequest, - setContextWindowOutputBufferForRequest, -} from "./contextWindowHookBridge"; -import { formatUsageLogLine } from "./usage/usage"; -import { parseToolInput, ToolCallAccumulator, type PendingToolCall } from "./toolCallAccumulator"; -import { getErrorMessage, isRecord, sleepWithCancellation } from "./utils"; - -export interface StreamRequestOptions { - url: string; - providerDisplayName: string; - apiKey: string; - modelId: string; - body: unknown; - requestHeaders: Record; - progress: vscode.Progress; - token: vscode.CancellationToken; - output?: vscode.OutputChannel; - debugReasoning: boolean; - requestTimeoutMs: number; - streamIdleTimeoutMs: number; - contextWindowOutputBuffer?: number; - authHeaders?: Record; - onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void; - capacityLimitedModelNotes?: Record; - onTransportSummary?: (summary: TransportRequestSummary) => void; - /** - * Whether `reasoning_content` should be surfaced as visible text instead of - * a thinking part. Computed UPSTREAM by the thinking provider strategy from - * the resolved thinking config — never inferred from the body here. - * - * Currently false for every family: reasoning models emit genuine CoT in - * `reasoning_content`, so it always goes to the thinking panel. (The old - * gateway #37635 mislabel is the gateway's bug, not worked around here.) - */ - treatReasoningAsContent?: boolean; - /** - * Controls whether `...` tags inlined in the model's text - * content are stripped and accumulated as reasoning content. - * - * - "never" — pass text through unchanged - * - "auto" — strip only for models known to inline thinking tags - * (currently: minimax-m*) - * - "always" — strip for every model - */ - stripThinkTags?: "never" | "auto" | "always"; -} - -export interface TransportRequestSummary { - providerDisplayName: string; - modelId: string; - url: string; - requestId?: string; - sessionId?: string; - status?: number; - contentType?: string; - payloadBytes: number; - totalBytes: number; - totalEvents: number; - durationMs: number; - ttfbMs?: number; - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - cachedTokens?: number; - finishReason?: string; - /** Credits for VS Code session cost (1 credit = $0.01). */ - copilotCredits?: number; - rateLimitSummary?: string; - abortedReason?: "request-timeout" | "stream-idle-timeout" | "cancelled"; - errorMessage?: string; -} - -export async function streamChatCompletions(options: StreamRequestOptions): Promise { - const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); - // Display decision: whether `reasoning_content` should be surfaced as visible - // text instead of a thinking part. Computed UPSTREAM by the thinking provider - // strategy from the resolved thinking config — not inferred from the body - // here. Currently false for all providers: reasoning_content is genuine CoT. - const isGoGateway = options.url.includes("/zen/go/"); - const body = options.body as Record | undefined; - const hasReasoningEffort = isGoGateway && bodyRequestsThinking(body); - const treatReasoningAsContent = options.treatReasoningAsContent ?? false; - if (isGoGateway) { - options.output?.appendLine( - `[go-gw] model=${options.modelId} hasReasoningEffort=${String(hasReasoningEffort)} treatReasoningAsContent=${String(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({ - ...options, - extractStreamParts: (data) => extractor.extractStreamParts(data), - extractFullParts: extractChatCompletionParts, - }); - - extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); - extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); - // Dormant marker path: no provider treats reasoning as visible text anymore - // (old gateway #37635 mislabel is not worked around), so flushReasoningMarker - // is a no-op today — kept as the designed seam. - const reasoningMarker = extractor.flushReasoningMarker(); - if (reasoningMarker) { - options.progress.report(reasoningMarker); - } - options.output?.appendLine( - `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, - ); - if (extractor.reasoningLoopSuppressed) { - options.output?.appendLine( - `[warn] model=${options.modelId} output suppressed after ~${String(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.`, - ); - // Intentionally not calling .show(true) — the diagnostic log is - // available in the Output pane when the user opens it manually. - } -} - -export async function streamAnthropicMessages(options: StreamRequestOptions): Promise { - const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); - const extractor = new AnthropicResponseExtractor( - options.onReasoningContent, - createReasoningDebugger(options.output, options.debugReasoning), - thinkFilter, - options.progress, - options.requestHeaders["x-opencode-request"], - ); - - await streamOpenCodeResponse({ - ...options, - extractStreamParts: (data) => extractor.extractStreamParts(data), - extractFullParts: extractAnthropicParts, - }); - - extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); - options.output?.appendLine( - `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, - ); -} - -export async function streamResponsesApi(options: StreamRequestOptions): Promise { - const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); - const extractor = new OpenAiResponseExtractor( - options.onReasoningContent, - createReasoningDebugger(options.output, options.debugReasoning), - thinkFilter, - options.progress, - options.requestHeaders["x-opencode-request"], - ); - - await streamOpenCodeResponse({ - ...options, - extractStreamParts: (data) => extractor.extractStreamParts(normalizeResponsesStreamEvent(data)), - extractFullParts: (data) => extractChatCompletionParts(normalizeResponsesFullResponse(data)), - }); - - extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); - extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); - options.output?.appendLine( - `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, - ); -} - -export async function streamGoogleGenerateContent(options: StreamRequestOptions): Promise { - const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); - const extractor = new OpenAiResponseExtractor( - options.onReasoningContent, - createReasoningDebugger(options.output, options.debugReasoning), - thinkFilter, - options.progress, - options.requestHeaders["x-opencode-request"], - ); - - await streamOpenCodeResponse({ - ...options, - url: `${options.url}:streamGenerateContent?alt=sse`, - extractStreamParts: (data) => extractor.extractStreamParts(normalizeGoogleStreamEvent(data)), - extractFullParts: (data) => extractChatCompletionParts(normalizeGoogleFullResponse(data)), - }); - - extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); - extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); - options.output?.appendLine( - `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, - ); -} - -interface StreamOpenCodeResponseOptions extends StreamRequestOptions { - extractStreamParts: (data: unknown) => vscode.LanguageModelResponsePart[]; - extractFullParts: (data: unknown) => vscode.LanguageModelResponsePart[]; -} - -interface RequestUsageSummary { - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - cachedTokens?: number; - finishReason?: string; - copilotCredits?: number; -} - -function reportProgressPart( - localRequestId: string | undefined, - progress: vscode.Progress, - part: vscode.LanguageModelResponsePart2, -): void { - if (!localRequestId) { - progress.report(part); - return; - } - - reportProgressWithContextWindowRequest(localRequestId, progress, part); -} - /** - * CONTRACT — Reasoning surfacing via LanguageModelThinkingPart - * - * RULES: - * 1. `LanguageModelThinkingPart` is a proposed VS Code API available at - * runtime since VS Code ~1.102 (Aug 2025). Our `engines.vscode: ^1.125.0` - * guarantees it is present, but we guard defensively so the extension - * degrades gracefully on any hypothetical older host. - * 2. When available, reasoning is streamed to the Copilot Chat UI per-chunk - * as a thinking part. This lets `chat.agent.thinkingStyle` - * (`collapsed` / `collapsedPreview` / `fixedScrolling`) apply, fixing - * issues #22 and #71. - * 3. When NOT available (very old host), the caller falls back to the - * legacy accumulate-and-flush behavior (reasoning emitted as a - * LanguageModelTextPart only when the response is otherwise empty). - * - * INVARIANTS: - * - Never throws: if the constructor is missing or `progress.report` fails, - * the reasoning is silently dropped (the visible response is unaffected). - * - The returned boolean tells the caller whether the thinking part was - * successfully emitted, so the caller can decide whether to also - * accumulate into `reasoningContent` for the legacy fallback path. + * Barrel — the streaming module was split into `src/transports/` (one entry + * per transport + shared engine/extractors/SSE) and `src/core/transport.ts` + * (the transport contract types). This module re-exports the historical + * public API so existing importers (extension.ts, goUsageTracker.ts) keep + * working during the refactor. */ -const thinkingPartConstructor: (new (value: string | string[]) => vscode.LanguageModelResponsePart2) | undefined = (() => { - const ctor = ( - vscode as unknown as { - LanguageModelThinkingPart?: unknown; - } - ).LanguageModelThinkingPart; - return typeof ctor === "function" ? (ctor as new (value: string | string[]) => vscode.LanguageModelResponsePart2) : undefined; -})(); - -/** - * Emit a reasoning chunk to the Copilot Chat UI as a thinking part. - * - * @returns `true` if the thinking part was emitted successfully; - * `false` if the API is unavailable (caller should accumulate - * for the legacy fallback path). - */ -function emitThinkingPart( - localRequestId: string | undefined, - progress: vscode.Progress, - reasoningChunk: string, -): boolean { - if (!reasoningChunk || !thinkingPartConstructor) { - return false; - } - try { - reportProgressPart(localRequestId, progress, new thinkingPartConstructor(reasoningChunk)); - return true; - } catch { - // Defensive: never let a thinking-part emit failure break the visible response. - return false; - } -} - -async function streamOpenCodeResponse(options: StreamOpenCodeResponseOptions): Promise { - const controller = new AbortController(); - const startedAt = Date.now(); - const localRequestId = options.requestHeaders["x-opencode-request"]; - let firstByteAt: number | undefined; - const usageSummary: RequestUsageSummary = {}; - let abortReason: "request-timeout" | "stream-idle-timeout" | "cancelled" | undefined; - let responseStatus: number | undefined; - let responseContentType: string | undefined; - let emittedSummary = false; - const abort = (reason: typeof abortReason) => { - abortReason ??= reason; - controller.abort(); - }; - const cancellation = options.token.onCancellationRequested(() => { - abort("cancelled"); - }); - const requestTimeout = setTimeout(() => { - abort("request-timeout"); - }, options.requestTimeoutMs); - let streamIdleTimeout: ReturnType | undefined; - const resetStreamIdleTimeout = () => { - if (streamIdleTimeout) { - clearTimeout(streamIdleTimeout); - } - streamIdleTimeout = setTimeout(() => { - abort("stream-idle-timeout"); - }, options.streamIdleTimeoutMs); - }; - const emitSummary = (totalBytes: number, totalEvents: number, extra?: Partial) => { - if (emittedSummary) { - return; - } - emittedSummary = true; - const summary: TransportRequestSummary = { - providerDisplayName: options.providerDisplayName, - modelId: options.modelId, - url: options.url, - requestId: options.requestHeaders["x-opencode-request"], - sessionId: options.requestHeaders["x-opencode-session"], - status: responseStatus, - contentType: responseContentType, - payloadBytes: - typeof options.body === "string" ? options.body.length : new TextEncoder().encode(JSON.stringify(options.body)).byteLength, - totalBytes, - totalEvents, - durationMs: Date.now() - startedAt, - ...(firstByteAt === undefined ? {} : { ttfbMs: firstByteAt - startedAt }), - ...(usageSummary.promptTokens === undefined ? {} : { promptTokens: usageSummary.promptTokens }), - ...(usageSummary.completionTokens === undefined ? {} : { completionTokens: usageSummary.completionTokens }), - ...(usageSummary.totalTokens === undefined ? {} : { totalTokens: usageSummary.totalTokens }), - ...(usageSummary.cachedTokens === undefined ? {} : { cachedTokens: usageSummary.cachedTokens }), - ...(usageSummary.finishReason === undefined ? {} : { finishReason: usageSummary.finishReason }), - ...extra, - }; - - // Let the caller enrich the summary (e.g. add copilotCredits) before - // we create the usage data parts, so VS Code session cost works. - options.onTransportSummary?.(summary); - - options.output?.appendLine( - `[response-summary] status=${String(summary.status ?? "n/a")} durationMs=${String(summary.durationMs)} ttfbMs=${String(summary.ttfbMs ?? "n/a")} promptTokens=${String(summary.promptTokens ?? "n/a")} completionTokens=${String(summary.completionTokens ?? "n/a")} totalTokens=${String(summary.totalTokens ?? "n/a")} cachedTokens=${String(summary.cachedTokens ?? "n/a")} finishReason=${summary.finishReason ?? ""} totalBytes=${String(summary.totalBytes)} totalEvents=${String(summary.totalEvents)}`, - ); - const usageLog = formatUsageLogLine({ - promptTokens: summary.promptTokens, - completionTokens: summary.completionTokens, - totalTokens: summary.totalTokens, - cachedTokens: summary.cachedTokens, - finishReason: summary.finishReason, - }); - if (usageLog) { - options.output?.appendLine(`[usage] ${usageLog}`); - } - - if (localRequestId) { - reportUsageToContextWindowForRequest(localRequestId, { - promptTokens: summary.promptTokens, - completionTokens: summary.completionTokens, - totalTokens: summary.totalTokens, - cachedTokens: summary.cachedTokens, - finishReason: summary.finishReason, - }); - } - - const usageParts = - summary.errorMessage || summary.abortedReason - ? [] - : createUsageDataParts({ - promptTokens: summary.promptTokens, - completionTokens: summary.completionTokens, - totalTokens: summary.totalTokens, - cachedTokens: summary.cachedTokens, - finishReason: summary.finishReason, - copilotCredits: summary.copilotCredits, - }); - for (const usagePart of usageParts) { - reportProgressPart(localRequestId, options.progress, usagePart); - } - }; - - try { - if (localRequestId && options.contextWindowOutputBuffer !== undefined) { - setContextWindowOutputBufferForRequest(localRequestId, options.contextWindowOutputBuffer); - } - - const rawPayload = JSON.stringify(options.body); - - // Log request for debugging latency. - options.output?.appendLine( - `[request] url=${options.url} payloadBytes=${String(rawPayload.length)} requestTimeoutMs=${String(options.requestTimeoutMs)} streamIdleTimeoutMs=${String(options.streamIdleTimeoutMs)}`, - ); - - // ------------------------------------------------------------------ - // NOTE: We do NOT gzip-compress the payload. The OpenCode proxy - // does not support Content-Encoding: gzip and returns HTTP 500. - // ------------------------------------------------------------------ - let payload = rawPayload; - const fetchHeaders: Record = { - ...(options.authHeaders ?? { Authorization: `Bearer ${options.apiKey}` }), - "Content-Type": "application/json", - ...options.requestHeaders, - }; - const fetchWithBody = (body: string) => - fetch(options.url, { - method: "POST", - headers: fetchHeaders, - body, - signal: controller.signal, - }); - - let response = await fetchWithBody(payload); - - // --- Runtime retry for recoverable HTTP 400 errors --- - // If the upstream rejects a parameter or reports an exact context overflow, - // patch the body and retry once. This handles tokenizer differences, stale - // models.dev metadata, and provider API changes without a hard user failure. - let consumedErrorBody: string | undefined; - if (response.status === 400) { - const errorDetail = await response.text(); - consumedErrorBody = errorDetail; - options.output?.appendLine(`[http-error-body] ${errorDetail.trim() ? truncateForLog(errorDetail) : ""}`); - const parsedBody = JSON.parse(rawPayload) as Record; - const patch = analyzeHttp400ForRetry(errorDetail, parsedBody); - if (patch) { - options.output?.appendLine(`[retry] HTTP 400 recoverable: ${patch.reason}. Retrying with patched body…`); - payload = JSON.stringify(patch.body); - response = await fetchWithBody(payload); - options.output?.appendLine(`[retry] Response after patch: ${String(response.status)} ${response.statusText}`); - // If retry also returned 400, consume its body so the normal error - // handler below doesn't try to re-read (the stream is already consumed). - if (!response.ok && response.status === 400) { - consumedErrorBody = await response.text(); - } else { - // The patched retry produced a fresh (non-consumed) body, so any - // stored 400 detail no longer matches the current response. - consumedErrorBody = undefined; - } - } - } - - // --- Transient 5xx retry (gateway/router capacity) --- - // Retry a small number of times with exponential backoff (plus jitter) - // when the gateway is momentarily unavailable (502/503/504, or 5xx body - // that names Router.Unavailable). Cancellation aborts the wait immediately. - let attempt = 0; - while (attempt < TRANSIENT_5XX_MAX_RETRIES && isTransientServerError(response.status, consumedErrorBody ?? "")) { - attempt += 1; - // Jitter spreads concurrent retries so they don't pile on the gateway - // at the same timestamp. - const backoffMs = Math.round(TRANSIENT_5XX_RETRY_BASE_MS * 2 ** (attempt - 1) + Math.random() * TRANSIENT_5XX_RETRY_JITTER_MS); - options.output?.appendLine( - `[retry] transient ${String(response.status)} (attempt ${String(attempt)}/${String(TRANSIENT_5XX_MAX_RETRIES)}); retrying in ${String(backoffMs)}ms…`, - ); - await sleepWithCancellation(backoffMs, options.token); - if (options.token.isCancellationRequested) { - break; - } - response = await fetchWithBody(payload); - // A fresh response may carry a new error body; drop stale 400 detail. - consumedErrorBody = undefined; - } - - responseStatus = response.status; - responseContentType = response.headers.get("content-type") ?? ""; - options.output?.appendLine(`[http] ${String(response.status)} ${response.statusText} content-type=${responseContentType || ""}`); - const rateLimitSummary = formatRateLimitSummary(readRateLimitInfo(response.headers)); - if (rateLimitSummary) { - options.output?.appendLine(`[rate-limit] ${rateLimitSummary}`); - } - - if (!response.ok) { - // Use already-consumed body if available (from retry logic above), - // otherwise read from the response stream. - const detail = consumedErrorBody ?? (await response.text()); - options.output?.appendLine(`[http-error-body] ${detail.trim() ? truncateForLog(detail) : ""}`); - const capacityHint = - options.capacityLimitedModelNotes?.[options.modelId] && response.status >= 500 - ? ` — ${options.capacityLimitedModelNotes[options.modelId]}` - : ""; - const requestError = buildOpenCodeRequestError( - options.providerDisplayName, - response, - detail, - options.modelId, - payload.length, - capacityHint, - ); - emitSummary(new TextEncoder().encode(detail).byteLength, 0, { - errorMessage: requestError.message, - rateLimitSummary, - }); - throw requestError; - } - - if (!response.body || !responseContentType.includes("text/event-stream")) { - const raw = await response.text(); - firstByteAt ??= Date.now(); - options.output?.appendLine(`[non-stream-body] ${truncateForLog(raw)}`); - let data: unknown; - try { - data = JSON.parse(raw); - } catch { - data = undefined; - } - if (data !== undefined) { - updateRequestUsageSummary(usageSummary, data); - for (const part of options.extractFullParts(data)) { - reportProgressPart(localRequestId, options.progress, part); - } - } - emitSummary(new TextEncoder().encode(raw).byteLength, data === undefined ? 0 : 1, { - rateLimitSummary, - }); - return; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; - let totalBytes = 0; - let totalEvents = 0; - // Diagnostic: collect raw SSE data when response is empty to identify - // format mismatches between gateway output and our extractor (issue #93). - const rawSseData: unknown[] = []; - let extractedPartCount = 0; - resetStreamIdleTimeout(); - - while (!options.token.isCancellationRequested) { - const { value, done } = await reader.read(); - if (done) { - break; - } - resetStreamIdleTimeout(); - - totalBytes += value.byteLength; - if (firstByteAt === undefined && value.byteLength > 0) { - firstByteAt = Date.now(); - } - const chunk = decoder.decode(value, { stream: true }); - if (options.debugReasoning && options.output && chunk) { - options.output.appendLine(`[sse-raw bytes=${String(value.byteLength)}] ${truncateForLog(chunk)}`); - } - buffer += chunk; - const events = buffer.split("\n\n"); - buffer = events.pop() ?? ""; - - for (const event of events) { - totalEvents += 1; - if (options.debugReasoning && options.output && event.trim()) { - options.output.appendLine(`[sse] ${truncateForLog(event)}`); - } - for (const part of parseServerSentEvent(event, options.extractStreamParts, (data) => { - updateRequestUsageSummary(usageSummary, data); - rawSseData.push(data); - })) { - extractedPartCount += 1; - reportProgressPart(localRequestId, options.progress, part); - } - } - } - - if (buffer.trim()) { - if (options.debugReasoning && options.output) { - options.output.appendLine(`[sse-tail] ${truncateForLog(buffer)}`); - } - for (const part of parseServerSentEvent(buffer, options.extractStreamParts, (data) => { - updateRequestUsageSummary(usageSummary, data); - rawSseData.push(data); - })) { - extractedPartCount += 1; - reportProgressPart(localRequestId, options.progress, part); - } - } - - if (options.debugReasoning && options.output) { - options.output.appendLine( - `[sse-stats] totalBytes=${String(totalBytes)} totalEvents=${String(totalEvents)} bufferTailLen=${String(buffer.length)}`, - ); - } - - // Diagnostic: when the gateway reported completion tokens but our - // extractor found nothing, dump raw SSE data to identify format mismatches. - // This helps diagnose issues like #93 where the model generates tokens - // but the response content is in an unrecognized format. - if (usageSummary.completionTokens && usageSummary.completionTokens > 0 && extractedPartCount === 0 && rawSseData.length > 0) { - options.output?.appendLine( - `[diag-empty-response] model=${options.modelId} completionTokens=${String(usageSummary.completionTokens)} totalEvents=${String(totalEvents)} rawSseDataCount=${String(rawSseData.length)}`, - ); - for (let i = 0; i < rawSseData.length; i++) { - options.output?.appendLine(`[diag-sse-event-${String(i)}] ${truncateForLog(JSON.stringify(rawSseData[i]))}`); - } - } - - emitSummary(totalBytes, totalEvents, { rateLimitSummary }); - } catch (error) { - if (abortReason === "cancelled") { - emitSummary(0, 0, { - abortedReason: "cancelled", - errorMessage: "request cancelled", - }); - return; - } - if (abortReason === "request-timeout") { - const requestError = new OpenCodeRequestError( - `${options.providerDisplayName} request timed out after ${formatDuration(options.requestTimeoutMs)}.`, - `${options.providerDisplayName} did not start or finish the request within ${formatDuration(options.requestTimeoutMs)}. Try again later or reduce the request size.`, - ); - emitSummary(0, 0, { - abortedReason: "request-timeout", - errorMessage: requestError.message, - }); - throw requestError; - } - if (abortReason === "stream-idle-timeout") { - const requestError = new OpenCodeRequestError( - `${options.providerDisplayName} stream stalled for ${formatDuration(options.streamIdleTimeoutMs)} without new data.`, - `${options.providerDisplayName} stopped sending stream data for ${formatDuration(options.streamIdleTimeoutMs)}, so the request was cancelled to avoid leaving Copilot stuck.`, - ); - emitSummary(0, 0, { - abortedReason: "stream-idle-timeout", - errorMessage: requestError.message, - }); - throw requestError; - } - emitSummary(0, 0, { - errorMessage: getErrorMessage(error), - }); - throw error; - } finally { - clearTimeout(requestTimeout); - if (streamIdleTimeout) { - clearTimeout(streamIdleTimeout); - } - cancellation.dispose(); - if (localRequestId) { - clearContextWindowRequest(localRequestId); - } - } -} - -function parseServerSentEvent( - event: string, - extractParts: (data: unknown) => vscode.LanguageModelResponsePart[], - onData?: (data: unknown) => void, -): vscode.LanguageModelResponsePart[] { - const lines = event - .split(/\r?\n/) - .filter((line) => line.startsWith("data:")) - .map((line) => line.slice("data:".length).trim()); - - const parts: vscode.LanguageModelResponsePart[] = []; - - for (const line of lines) { - if (!line || line === "[DONE]") { - continue; - } - - try { - const data = JSON.parse(line) as unknown; - onData?.(data); - parts.push(...extractParts(data)); - } catch { - // Ignore malformed SSE lines; the API may send comments or keep-alive frames. - } - } - - return parts; -} - -function createReasoningDebugger( - output: vscode.OutputChannel | undefined, - enabled: boolean, -): ((reasoningContent: string) => void) | undefined { - if (!enabled || !output) { - return undefined; - } - - return (reasoningContent) => { - output.appendLine("[reasoning_content]"); - output.appendLine(reasoningContent); - output.appendLine("[/reasoning_content]"); - }; -} - -// --------------------------------------------------------------------------- -// ThinkTagFilter — streaming stripper for inline `...` tags -// -// Some models (notably MiniMax M-series) inline their chain-of-thought -// directly inside the `content` text field wrapped in `` / `` -// tags rather than using a dedicated `reasoning_content` field. When this -// raw text is emitted to the VS Code chat UI the reasoning "leaks" into the -// visible response, making it unreadable. -// -// The filter processes text **as it arrives** (potentially split across many -// SSE chunks) and separates it into: -// • `visibleText` — content outside think tags (emitted to chat) -// • `thinkingText` — content inside think tags (accumulated as reasoning) -// -// Edge cases handled: -// - `` or `` split across chunk boundaries -// - Unclosed `` at end of stream (flushed as thinking on `finish()`) -// - Leading whitespace immediately after opening `` is trimmed -// --------------------------------------------------------------------------- - -const OPEN_THINK_TAG = ""; -const CLOSE_THINK_TAG = ""; - -function shouldStripThinkTags(mode: "never" | "auto" | "always" | undefined, modelId: string): boolean { - if (mode === "always") { - return true; - } - if (mode === "never" || mode === undefined) { - return false; - } - // "auto" — strip only for models known to inline thinking tags - return /^minimax-m/i.test(modelId); -} - -function createThinkTagFilter(mode: "never" | "auto" | "always" | undefined, modelId: string): ThinkTagFilter | undefined { - return shouldStripThinkTags(mode, modelId) ? new ThinkTagFilter() : undefined; -} - -class ThinkTagFilter { - /** Partial text carried over from the previous chunk for boundary matching. */ - private carry = ""; - /** Whether we are currently inside a `` block. */ - private insideThink = false; - - /** - * Process an incoming text chunk. - * Returns `{ visible, thinking }` where `visible` is safe to emit to the - * chat and `thinking` should be accumulated as reasoning content. - */ - process(chunk: string): { visible: string; thinking: string } { - if (!chunk) { - return { visible: "", thinking: "" }; - } - - // Prepend carry from the previous chunk so boundary tags can be detected - // even when they are split across chunks. - const buffer = this.carry + chunk; - this.carry = ""; - - let visible = ""; - let thinking = ""; - let pos = 0; - const maxScan = Math.max(OPEN_THINK_TAG.length, CLOSE_THINK_TAG.length); - - while (pos < buffer.length) { - if (this.insideThink) { - // Look for closing - const closeIdx = buffer.indexOf(CLOSE_THINK_TAG, pos); - if (closeIdx === -1) { - // No closing tag found — consume the rest, but keep a tail for - // boundary matching in the next chunk. - const safeEnd = buffer.length - maxScan; - if (safeEnd > pos) { - thinking += buffer.slice(pos, safeEnd); - this.carry = buffer.slice(safeEnd); - } else { - // Entire remaining buffer is shorter than max scan — carry it all - this.carry = buffer.slice(pos); - } - break; - } - // Found closing tag - thinking += buffer.slice(pos, closeIdx); - pos = closeIdx + CLOSE_THINK_TAG.length; - this.insideThink = false; - // Skip a single leading whitespace after for cleaner output - if (pos < buffer.length && (buffer[pos] === "\n" || buffer[pos] === "\r")) { - pos += 1; - if (pos < buffer.length && buffer[pos] === "\n") { - pos += 1; - } - } - } else { - // Look for opening - const openIdx = buffer.indexOf(OPEN_THINK_TAG, pos); - if (openIdx === -1) { - // No opening tag — emit visible text but keep a tail for boundary - const safeEnd = buffer.length - maxScan; - if (safeEnd > pos) { - visible += buffer.slice(pos, safeEnd); - this.carry = buffer.slice(safeEnd); - } else { - this.carry = buffer.slice(pos); - } - break; - } - // Found opening tag - visible += buffer.slice(pos, openIdx); - pos = openIdx + OPEN_THINK_TAG.length; - this.insideThink = true; - // Skip a single leading whitespace after - if (pos < buffer.length && (buffer[pos] === "\n" || buffer[pos] === "\r")) { - pos += 1; - if (pos < buffer.length && buffer[pos] === "\n") { - pos += 1; - } - } - } - } - - return { visible, thinking }; - } - - /** - * Call at end of stream to flush any remaining carry. - * If we were inside an unclosed ``, that content is treated as - * thinking. Otherwise the remaining carry is visible text. - */ - finish(): { visible: string; thinking: string } { - const remaining = this.carry; - this.carry = ""; - if (this.insideThink) { - // Unclosed think tag at end of stream — treat as thinking - this.insideThink = false; - return { visible: "", thinking: remaining }; - } - return { visible: remaining, thinking: "" }; - } -} - -/** - * Shared state + behavior for the OpenAI- and Anthropic-style stream - * extractors: text/reasoning accounting, think-tag filtering, live reasoning - * emission, and the end-of-stream reasoning fallback. - */ -abstract class BaseResponseExtractor { - protected reasoningContent = ""; - protected emittedTextLength = 0; - protected emittedToolCallsCount = 0; - /** - * Total reasoning characters seen across the entire stream (monotonic). - * Unlike `reasoningContent` (cleared by tool-call flushes), this counter is - * used for the [stream-summary] log line so metrics stay accurate. - */ - protected totalReasoningChars = 0; - - constructor( - protected readonly onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void, - protected readonly onReasoningDebug?: (reasoningContent: string) => void, - protected readonly thinkFilter?: ThinkTagFilter, - protected readonly progress?: vscode.Progress, - protected readonly localRequestId?: string, - protected readonly output?: vscode.OutputChannel, - ) {} - - get emittedText(): number { - return this.emittedTextLength; - } - - get emittedTools(): number { - return this.emittedToolCallsCount; - } - - get reasoningChars(): number { - return this.totalReasoningChars; - } - - /** Split text through the think-tag filter (if active). */ - protected filterText(text: string): { visible: string; thinking: string } { - if (!text) { - return { visible: "", thinking: "" }; - } - if (!this.thinkFilter) { - return { visible: text, thinking: "" }; - } - return this.thinkFilter.process(text); - } - - /** - * Accumulate reasoning for tool-call replication and — when the thinking - * part API is available — stream it live to the Copilot Chat UI as - * `LanguageModelThinkingPart` so `chat.agent.thinkingStyle` applies. - */ - protected handleReasoning(reasoning: string): string { - if (!reasoning) { - return ""; - } - this.reasoningContent += reasoning; - this.totalReasoningChars += reasoning.length; - if (this.progress) { - emitThinkingPart(this.localRequestId, this.progress, reasoning); - } - return reasoning; - } - - /** Emit the shared end-of-stream reasoning fallback. */ - flushReasoningFallback(progress: vscode.Progress, localRequestId?: string): void { - // Flush any remaining text in the think filter - if (this.thinkFilter) { - const { visible, thinking } = this.thinkFilter.finish(); - if (visible) { - this.emittedTextLength += visible.length; - reportProgressPart(localRequestId, progress, new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - // Surface remaining think-filter carry through the thinking part channel. - this.handleReasoning(thinking); - } - } - - const reasoning = this.reasoningContent.trim(); - if (!reasoning) { - return; - } - // If the thinking part API is available, reasoning was already streamed - // live during extractStreamParts via handleReasoning(). The accumulated - // reasoningContent is retained only for tool-call replication - // (flushToolCalls → onReasoningContent). Nothing more to emit here. - if (thinkingPartConstructor) { - this.reasoningContent = ""; - return; - } - // Legacy fallback (API unavailable): emit reasoning as plain text only - // when the response is otherwise empty, to avoid breaking the visible - // output. This preserves the pre-fix safety-net semantics. - if (this.emittedTextLength > 0 || this.emittedToolCallsCount > 0) { - this.reasoningContent = ""; - return; - } - this.onReasoningDebug?.(this.reasoningContent); - reportProgressPart(localRequestId, progress, new vscode.LanguageModelTextPart(reasoning)); - this.emittedTextLength += reasoning.length; - this.reasoningContent = ""; - } -} - -class OpenAiResponseExtractor extends BaseResponseExtractor { - private readonly toolCallAccumulator = new ToolCallAccumulator(); - /** - * 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; - /** Reasoning emitted as visible text (gateway bug #37635, thinking OFF). */ - private reasoningAsContent = ""; - - constructor( - onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void, - onReasoningDebug?: (reasoningContent: string) => void, - thinkFilter?: ThinkTagFilter, - progress?: vscode.Progress, - localRequestId?: string, - output?: vscode.OutputChannel, - /** - * Display seam: whether `reasoning_content` should be emitted as a visible - * `LanguageModelTextPart` instead of a thinking part. - * - * Computed upstream by the thinking provider strategy. Currently always - * false — reasoning models emit genuine CoT in `reasoning_content`, so it - * goes to the thinking panel. (The old gateway #37635 mislabel is not - * worked around.) - */ - private readonly treatReasoningAsContent = false, - ) { - super(onReasoningContent, onReasoningDebug, thinkFilter, progress, localRequestId, output); - } - - /** Whether the reasoning loop suppression was triggered. */ - get reasoningLoopSuppressed(): boolean { - return this._reasoningLoopSuppressed; - } - - /** - * Emit an internal marker part carrying the reasoning that was surfaced as - * visible text, so the next turn can echo it back as reasoning_content. - * Called after the stream completes; the transcript keeps the marker. - */ - flushReasoningMarker(): vscode.LanguageModelResponsePart2 | undefined { - if (!this.treatReasoningAsContent || !this.reasoningAsContent) { - return undefined; - } - const part = createReasoningMarkerPart(this.reasoningAsContent); - this.reasoningAsContent = ""; - return part; - } - - /** - * 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 the same 40-char suffix repeats across - * 6+ consecutive chunks, the model is stuck and further thinking parts are - * suppressed (accumulation continues for tool-call replication). - */ - override handleReasoning(reasoning: string): string { - if (!reasoning) { - return ""; - } - this.reasoningContent += reasoning; - this.totalReasoningChars += reasoning.length; - - 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. - if (this.progress) { - emitThinkingPart(this.localRequestId, this.progress, reasoning); - } - 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. - */ - 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 >= OpenAiResponseExtractor.REASONING_LOOP_SUFFIX_MATCHES) { - 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 []; - } - - const first: unknown = data.choices[0]; - if (!isRecord(first)) { - return []; - } - - const parts: vscode.LanguageModelResponsePart[] = []; - const delta = first.delta; - if (isRecord(delta)) { - const text = extractTextFromDelta(delta); - const { visible, thinking } = this.filterText(text); - if (visible) { - this.emittedTextLength += visible.length; - parts.push(new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - this.handleReasoning(thinking); - } - const reasoning = extractReasoningFromDelta(delta); - if (reasoning) { - // Dormant display seam: were treatReasoningAsContent true AND - // delta.content empty, reasoning_content would be emitted as visible - // text (old gateway #37635 mislabel). Never set today — reasoning is - // genuine CoT and goes to the thinking panel. Loop guard still applies. - if (this.treatReasoningAsContent && !visible && text.length === 0) { - if (!this.shouldSuppressThinkingEmit(reasoning)) { - this.emittedTextLength += reasoning.length; - this.reasoningAsContent += reasoning; - parts.push(new vscode.LanguageModelTextPart(reasoning)); - } - } else { - this.handleReasoning(reasoning); - } - } - this.collectOpenAiToolCalls(delta.tool_calls); - } - - const message = first.message; - if (isRecord(message)) { - const text = extractTextFromDelta(message); - const { visible, thinking } = this.filterText(text); - if (visible) { - this.emittedTextLength += visible.length; - parts.push(new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - this.handleReasoning(thinking); - } - const reasoning = extractReasoningFromDelta(message); - if (reasoning) { - this.handleReasoning(reasoning); - } - this.collectOpenAiToolCalls(message.tool_calls); - } - - // Flush accumulated tool calls ONLY when the stream reports the OpenAI - // `"tool_calls"` finish reason. Intermediate chunks always carry - // `finish_reason: null`, so flushing there would emit an incomplete tool - // call (empty arguments → `` without ``, issue #98). - // Gateways that omit `finish_reason` entirely (e.g. OpenCode Go for - // gpt-5.6-luna, issue #93) are flushed once at end-of-stream via - // `flushRemainingToolCalls()`. - if (ToolCallAccumulator.shouldFlushOnFinishReason(first.finish_reason)) { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); - } - - return parts; - } - - override flushReasoningFallback(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; - } - super.flushReasoningFallback(progress, localRequestId); - } - - private collectOpenAiToolCalls(toolCalls: unknown): void { - this.toolCallAccumulator.collect(toolCalls); - } - - private flushToolCalls(): vscode.LanguageModelToolCallPart[] { - const calls = this.toolCallAccumulator.flush(); - const parts = calls.map( - (call, index) => - new vscode.LanguageModelToolCallPart(call.id || `opencodego-tool-${String(Date.now())}-${String(index)}`, call.name, call.input), - ); - - if (this.reasoningContent.trim()) { - this.onReasoningDebug?.(this.reasoningContent); - this.onReasoningContent?.( - parts.map((part) => part.callId), - this.reasoningContent, - ); - } - - this.reasoningContent = ""; - return parts; - } - - /** - * Flush any tool calls still accumulated when the stream ends. Some - * gateways omit the `finish_reason: "tool_calls"` final event (e.g. the - * OpenCode Go gateway for gpt-5.6-luna, issue #93), so a final flush here - * prevents those calls from silently disappearing. - * - * Must be called BEFORE `flushReasoningFallback` so tool-call reasoning - * replication runs first. Safe no-op when nothing is pending. - */ - flushRemainingToolCalls(progress: vscode.Progress, localRequestId?: string): void { - if (this.toolCallAccumulator.size === 0) { - return; - } - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - for (const part of toolParts) { - reportProgressPart(localRequestId, progress, part); - } - } -} - -class AnthropicResponseExtractor extends BaseResponseExtractor { - private readonly pendingToolCalls = new Map(); - - extractStreamParts(data: unknown): vscode.LanguageModelResponsePart[] { - if (!isRecord(data)) { - return []; - } - - const parts: vscode.LanguageModelResponsePart[] = []; - const eventType = typeof data.type === "string" ? data.type : ""; - const delta = isRecord(data.delta) ? data.delta : undefined; - - // --- Handle Anthropic SSE event types --- - - // 1. content_block_start: contains the initial content block info. - // For tool_use blocks, the id and name are in data.content_block. - // For text blocks, data.content_block.text may contain initial text. - if (eventType === "content_block_start") { - const contentBlock = isRecord(data.content_block) ? data.content_block : undefined; - const index = typeof data.index === "number" ? data.index : this.pendingToolCalls.size; - - if (contentBlock && contentBlock.type === "tool_use") { - const pending = this.pendingToolCalls.get(index) ?? { - id: "", - name: "", - arguments: "", - }; - if (typeof contentBlock.id === "string") { - pending.id = contentBlock.id; - } - if (typeof contentBlock.name === "string") { - pending.name += contentBlock.name; - } - this.pendingToolCalls.set(index, pending); - } else if (contentBlock && contentBlock.type === "thinking" && typeof contentBlock.thinking === "string") { - this.handleReasoning(contentBlock.thinking); - } else if (contentBlock && typeof contentBlock.text === "string" && contentBlock.text.length > 0) { - const { visible, thinking } = this.filterText(contentBlock.text); - if (visible) { - this.emittedTextLength += visible.length; - parts.push(new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - this.handleReasoning(thinking); - } - } - - return parts; - } - - // 2. content_block_delta: streaming deltas for the current block. - // text delta: delta.type === "text_delta", delta.text - // thinking delta: delta.type === "thinking_delta", delta.thinking - // tool input delta: delta.type === "input_json_delta", delta.partial_json - if (eventType === "content_block_delta" && delta) { - if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) { - const { visible, thinking } = this.filterText(delta.text); - if (visible) { - this.emittedTextLength += visible.length; - parts.push(new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - this.handleReasoning(thinking); - } - } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string" && delta.thinking.length > 0) { - this.handleReasoning(delta.thinking); - } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { - const index = typeof data.index === "number" ? data.index : this.pendingToolCalls.size - 1; - const pending = this.pendingToolCalls.get(index) ?? { - id: "", - name: "", - arguments: "", - }; - pending.arguments += delta.partial_json; - this.pendingToolCalls.set(index, pending); - } - - return parts; - } - - // 3. message_delta: contains stop_reason and usage. Usage is already - // collected via the onData callback in parseServerSentEvent (which - // updates the real RequestUsageSummary); here we only flush tool calls. - if (eventType === "message_delta" && delta) { - if (delta.stop_reason) { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); - } - return parts; - } - - // 4. message_stop: final event, flush any remaining tool calls. - if (eventType === "message_stop") { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); - return parts; - } - - // --- Fallback: handle non-standard or flat SSE shapes --- - // Some providers may send Anthropic-style data without explicit event types, - // or use a flat delta shape similar to the original extractor logic. - if (delta) { - if (typeof delta.text === "string" && delta.text.length > 0) { - const { visible, thinking } = this.filterText(delta.text); - if (visible) { - this.emittedTextLength += visible.length; - parts.push(new vscode.LanguageModelTextPart(visible)); - } - if (thinking) { - this.handleReasoning(thinking); - } - } - - if (typeof delta.thinking === "string" && delta.thinking.length > 0) { - this.handleReasoning(delta.thinking); - } - if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { - this.handleReasoning(delta.reasoning_content); - } - if (typeof delta.reasoning === "string" && delta.reasoning.length > 0) { - this.handleReasoning(delta.reasoning); - } - - if (typeof delta.type === "string") { - // Flat tool_use delta (non-standard but some gateways use this) - if (delta.type === "tool_use") { - const index = typeof delta.index === "number" ? delta.index : this.pendingToolCalls.size; - const pending = this.pendingToolCalls.get(index) ?? { - id: "", - name: "", - arguments: "", - }; - if (typeof delta.id === "string") { - pending.id = delta.id; - } - if (typeof delta.name === "string") { - pending.name += delta.name; - } - if (typeof delta.input === "string") { - pending.arguments += delta.input; - } else if (isRecord(delta.input)) { - pending.arguments += JSON.stringify(delta.input); - } - this.pendingToolCalls.set(index, pending); - } - } - - if (delta.stop_reason) { - const toolParts = this.flushToolCalls(); - this.emittedToolCallsCount += toolParts.length; - parts.push(...toolParts); - } - } - - return parts; - } - - private flushToolCalls(): vscode.LanguageModelToolCallPart[] { - const toolCalls = Array.from(this.pendingToolCalls.values()).filter((toolCall) => toolCall.name); - const parts = toolCalls.map( - (toolCall, index) => - new vscode.LanguageModelToolCallPart( - toolCall.id || `opencodego-tool-${String(Date.now())}-${String(index)}`, - toolCall.name, - parseToolInput(toolCall.arguments), - ), - ); - - if (this.reasoningContent.trim()) { - this.onReasoningDebug?.(this.reasoningContent); - this.onReasoningContent?.( - parts.map((part) => part.callId), - this.reasoningContent, - ); - } - - this.pendingToolCalls.clear(); - this.reasoningContent = ""; - return parts; - } -} - -function extractChatCompletionParts(data: unknown): vscode.LanguageModelResponsePart[] { - if (!isRecord(data) || !Array.isArray(data.choices)) { - return []; - } - - const first: unknown = data.choices[0]; - if (!isRecord(first)) { - return []; - } - - const parts: vscode.LanguageModelResponsePart[] = []; - const message = first.message; - if (isRecord(message)) { - const text = extractTextFromDelta(message); - if (text) { - parts.push(new vscode.LanguageModelTextPart(text)); - } else { - const reasoning = extractReasoningFromDelta(message); - if (reasoning.trim()) { - // Non-stream path: emit reasoning via thinking part when the API is - // available (so chat.agent.thinkingStyle applies), else fall back to - // plain text. Cast needed because LanguageModelThinkingPart is in the - // LanguageModelResponsePart2 union, not the stable LanguageModelResponsePart. - const thinkingPart = thinkingPartConstructor - ? (new thinkingPartConstructor(reasoning) as unknown as vscode.LanguageModelResponsePart) - : new vscode.LanguageModelTextPart(reasoning); - parts.push(thinkingPart); - } - } - for (const toolCallPart of toolCallPartsFromOpenAiMessage(message.tool_calls)) { - parts.push(toolCallPart); - } - } - - if (typeof first.text === "string") { - parts.push(new vscode.LanguageModelTextPart(first.text)); - } - - return parts; -} - -function extractTextFromDelta(delta: Record): string { - const candidates: unknown[] = [delta.content, delta.text, delta.output_text]; - let collected = ""; - for (const candidate of candidates) { - if (typeof candidate === "string" && candidate.length > 0) { - collected += candidate; - continue; - } - if (Array.isArray(candidate)) { - for (const part of candidate) { - if (typeof part === "string") { - collected += part; - } else if (isRecord(part)) { - const text = part.text ?? part.value ?? part.output_text; - if (typeof text === "string") { - collected += text; - } - } - } - } - } - return collected; -} - -function extractReasoningFromDelta(delta: Record): string { - const candidates: unknown[] = [ - delta.reasoning_content, - delta.reasoning, - delta.thinking, - isRecord(delta.message) ? delta.message.reasoning_content : undefined, - ]; - let collected = ""; - for (const candidate of candidates) { - if (typeof candidate === "string") { - collected += candidate; - } else if (isRecord(candidate) && typeof candidate.content === "string") { - collected += candidate.content; - } else if (Array.isArray(candidate)) { - for (const part of candidate) { - if (typeof part === "string") { - collected += part; - } else if (isRecord(part) && typeof part.text === "string") { - collected += part.text; - } - } - } - } - return collected; -} - -function extractAnthropicParts(data: unknown): vscode.LanguageModelResponsePart[] { - if (!isRecord(data) || !Array.isArray(data.content)) { - return []; - } - - const parts: vscode.LanguageModelResponsePart[] = []; - const textParts: string[] = []; - const reasoningParts: string[] = []; - - for (const block of data.content) { - if (!isRecord(block)) { - continue; - } - - if (typeof block.text === "string" && block.text.length > 0) { - textParts.push(block.text); - continue; - } - - // Anthropic thinking blocks — surface via thinking part when available. - if ( - (block.type === "thinking" || block.type === "redacted_thinking") && - typeof block.thinking === "string" && - block.thinking.length > 0 - ) { - reasoningParts.push(block.thinking); - continue; - } - - if (block.type === "tool_use" && typeof block.name === "string") { - const id = typeof block.id === "string" ? block.id : `opencodego-tool-${String(Date.now())}`; - const input = isRecord(block.input) ? block.input : parseToolInput(typeof block.input === "string" ? block.input : "{}"); - parts.push(new vscode.LanguageModelToolCallPart(id, block.name, input)); - } - } - - const text = textParts.join(""); - if (text) { - parts.unshift(new vscode.LanguageModelTextPart(text)); - } - - // Emit accumulated reasoning via thinking part (or text fallback) at the front. - const reasoning = reasoningParts.join(""); - if (reasoning) { - const thinkingPart = thinkingPartConstructor - ? (new thinkingPartConstructor(reasoning) as unknown as vscode.LanguageModelResponsePart) - : new vscode.LanguageModelTextPart(reasoning); - parts.unshift(thinkingPart); - } - - return parts; -} - -function toolCallPartsFromOpenAiMessage(toolCalls: unknown): vscode.LanguageModelToolCallPart[] { - if (!Array.isArray(toolCalls)) { - return []; - } - - return toolCalls - .filter(isRecord) - .map((toolCall, index) => { - const fn = toolCall.function; - const id = typeof toolCall.id === "string" ? toolCall.id : `opencodego-tool-${String(Date.now())}-${String(index)}`; - const name = isRecord(fn) && typeof fn.name === "string" ? fn.name : ""; - const args = isRecord(fn) && typeof fn.arguments === "string" ? fn.arguments : "{}"; - return name ? new vscode.LanguageModelToolCallPart(id, name, parseToolInput(args)) : undefined; - }) - .filter((part): part is vscode.LanguageModelToolCallPart => Boolean(part)); -} - -function updateRequestUsageSummary(summary: RequestUsageSummary, data: unknown): void { - if (!isRecord(data)) { - return; - } - - const usage = isRecord(data.usage) ? data.usage : undefined; - if (usage) { - // OpenAI-compatible fields - const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : undefined; - const completionTokens = typeof usage.completion_tokens === "number" ? usage.completion_tokens : undefined; - const totalTokens = typeof usage.total_tokens === "number" ? usage.total_tokens : undefined; - const promptTokenDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : undefined; - const cachedTokens = - promptTokenDetails && typeof promptTokenDetails.cached_tokens === "number" ? promptTokenDetails.cached_tokens : undefined; - - // Anthropic-compatible fields (input_tokens / output_tokens) - const anthropicInputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined; - const anthropicOutputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : undefined; - const cacheReadInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined; - - if (promptTokens !== undefined) { - summary.promptTokens = promptTokens; - } else if (anthropicInputTokens !== undefined) { - summary.promptTokens = anthropicInputTokens; - } - if (completionTokens !== undefined) { - summary.completionTokens = completionTokens; - } else if (anthropicOutputTokens !== undefined) { - summary.completionTokens = anthropicOutputTokens; - } - if (totalTokens !== undefined) { - summary.totalTokens = totalTokens; - } - if (cachedTokens !== undefined) { - summary.cachedTokens = cachedTokens; - } else if (cacheReadInputTokens !== undefined) { - summary.cachedTokens = cacheReadInputTokens; - } - } - - // Anthropic message_delta reports stop_reason in delta, not in choices - const delta = isRecord(data.delta) ? data.delta : undefined; - if (delta && typeof delta.stop_reason === "string") { - summary.finishReason = delta.stop_reason; - } - - const firstChoice = Array.isArray(data.choices) && isRecord(data.choices[0]) ? data.choices[0] : undefined; - if (firstChoice && typeof firstChoice.finish_reason === "string") { - summary.finishReason = firstChoice.finish_reason; - } -} +export { streamChatCompletions } from "./transports/chatCompletions"; +export { streamAnthropicMessages } from "./transports/anthropic"; +export { streamResponsesApi } from "./transports/responses"; +export { streamGoogleGenerateContent } from "./transports/google"; +export { type StreamRequestOptions, type TransportRequestSummary } from "./core/transport"; diff --git a/src/transports/anthropic.ts b/src/transports/anthropic.ts new file mode 100644 index 0000000..82d5863 --- /dev/null +++ b/src/transports/anthropic.ts @@ -0,0 +1,28 @@ +import type { StreamRequestOptions } from "../core/transport"; +import { createThinkTagFilter } from "./thinkTags"; +import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; +import { AnthropicResponseExtractor } from "./extractors"; +import { extractAnthropicParts } from "./extract"; + +/** Anthropic Messages API transport (Claude-family / MiniMax m2.x / Qwen 3.x-plus). */ +export async function streamAnthropicMessages(options: StreamRequestOptions): Promise { + const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); + const extractor = new AnthropicResponseExtractor( + options.onReasoningContent, + createReasoningDebugger(options.output, options.debugReasoning), + thinkFilter, + options.progress, + options.requestHeaders["x-opencode-request"], + ); + + await streamOpenCodeResponse({ + ...options, + extractStreamParts: (data) => extractor.extractStreamParts(data), + extractFullParts: extractAnthropicParts, + }); + + extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); + options.output?.appendLine( + `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, + ); +} diff --git a/src/transports/chatCompletions.ts b/src/transports/chatCompletions.ts new file mode 100644 index 0000000..5ba6aba --- /dev/null +++ b/src/transports/chatCompletions.ts @@ -0,0 +1,64 @@ +import { bodyRequestsThinking } from "../thinking"; +import type { StreamRequestOptions } from "../core/transport"; +import { createThinkTagFilter } from "./thinkTags"; +import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; +import { OpenAiResponseExtractor } from "./extractors"; +import { extractChatCompletionParts } from "./extract"; + +/** OpenAI-compatible chat-completions transport. */ +export async function streamChatCompletions(options: StreamRequestOptions): Promise { + const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); + // Display decision: whether `reasoning_content` should be surfaced as visible + // text instead of a thinking part. Computed UPSTREAM by the thinking provider + // strategy from the resolved thinking config — not inferred from the body + // here. Currently false for all providers: reasoning_content is genuine CoT. + const isGoGateway = options.url.includes("/zen/go/"); + const body = options.body as Record | undefined; + const hasReasoningEffort = isGoGateway && bodyRequestsThinking(body); + const treatReasoningAsContent = options.treatReasoningAsContent ?? false; + if (isGoGateway) { + options.output?.appendLine( + `[go-gw] model=${options.modelId} hasReasoningEffort=${String(hasReasoningEffort)} treatReasoningAsContent=${String(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({ + ...options, + extractStreamParts: (data) => extractor.extractStreamParts(data), + extractFullParts: extractChatCompletionParts, + }); + + extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); + extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); + // Dormant marker path: no provider treats reasoning as visible text anymore + // (old gateway #37635 mislabel is not worked around), so flushReasoningMarker + // is a no-op today — kept as the designed seam. + const reasoningMarker = extractor.flushReasoningMarker(); + if (reasoningMarker) { + options.progress.report(reasoningMarker); + } + options.output?.appendLine( + `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, + ); + if (extractor.reasoningLoopSuppressed) { + options.output?.appendLine( + `[warn] model=${options.modelId} output suppressed after ~${String(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.`, + ); + // Intentionally not calling .show(true) — the diagnostic log is + // available in the Output pane when the user opens it manually. + } +} diff --git a/src/transports/engine.ts b/src/transports/engine.ts new file mode 100644 index 0000000..6941f20 --- /dev/null +++ b/src/transports/engine.ts @@ -0,0 +1,409 @@ +import type * as vscode from "vscode"; +import { + buildOpenCodeRequestError, + formatDuration, + formatRateLimitSummary, + OpenCodeRequestError, + readRateLimitInfo, + truncateForLog, +} from "../errors"; +import { + analyzeHttp400ForRetry, + isTransientServerError, + TRANSIENT_5XX_MAX_RETRIES, + TRANSIENT_5XX_RETRY_BASE_MS, + TRANSIENT_5XX_RETRY_JITTER_MS, +} from "../retry"; +import { createUsageDataParts } from "../chatParts"; +import { + clearContextWindowRequest, + reportUsageToContextWindowForRequest, + setContextWindowOutputBufferForRequest, +} from "../contextWindowHookBridge"; +import { formatUsageLogLine } from "../usage/usage"; +import { getErrorMessage, sleepWithCancellation } from "../utils"; +import { parseServerSentEvent } from "./sse"; +import { reportProgressPart, type RequestUsageSummary, type StreamOpenCodeResponseOptions } from "./streamParts"; +import type { TransportRequestSummary } from "../core/transport"; +import { updateRequestUsageSummary } from "./extract"; + +/** + * Core streaming engine shared by every transport: performs the HTTP POST + * (with HTTP-400 body patching and transient-5xx backoff retries), parses the + * SSE stream, routes each event through the injected extractor, and emits a + * per-request `TransportRequestSummary` on completion. + */ +export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOptions): Promise { + const controller = new AbortController(); + const startedAt = Date.now(); + const localRequestId = options.requestHeaders["x-opencode-request"]; + let firstByteAt: number | undefined; + const usageSummary: RequestUsageSummary = {}; + let abortReason: "request-timeout" | "stream-idle-timeout" | "cancelled" | undefined; + let responseStatus: number | undefined; + let responseContentType: string | undefined; + let emittedSummary = false; + const abort = (reason: typeof abortReason) => { + abortReason ??= reason; + controller.abort(); + }; + const cancellation = options.token.onCancellationRequested(() => { + abort("cancelled"); + }); + const requestTimeout = setTimeout(() => { + abort("request-timeout"); + }, options.requestTimeoutMs); + let streamIdleTimeout: ReturnType | undefined; + const resetStreamIdleTimeout = () => { + if (streamIdleTimeout) { + clearTimeout(streamIdleTimeout); + } + streamIdleTimeout = setTimeout(() => { + abort("stream-idle-timeout"); + }, options.streamIdleTimeoutMs); + }; + const emitSummary = (totalBytes: number, totalEvents: number, extra?: Partial) => { + if (emittedSummary) { + return; + } + emittedSummary = true; + const summary: TransportRequestSummary = { + providerDisplayName: options.providerDisplayName, + modelId: options.modelId, + url: options.url, + requestId: options.requestHeaders["x-opencode-request"], + sessionId: options.requestHeaders["x-opencode-session"], + status: responseStatus, + contentType: responseContentType, + payloadBytes: + typeof options.body === "string" ? options.body.length : new TextEncoder().encode(JSON.stringify(options.body)).byteLength, + totalBytes, + totalEvents, + durationMs: Date.now() - startedAt, + ...(firstByteAt === undefined ? {} : { ttfbMs: firstByteAt - startedAt }), + ...(usageSummary.promptTokens === undefined ? {} : { promptTokens: usageSummary.promptTokens }), + ...(usageSummary.completionTokens === undefined ? {} : { completionTokens: usageSummary.completionTokens }), + ...(usageSummary.totalTokens === undefined ? {} : { totalTokens: usageSummary.totalTokens }), + ...(usageSummary.cachedTokens === undefined ? {} : { cachedTokens: usageSummary.cachedTokens }), + ...(usageSummary.finishReason === undefined ? {} : { finishReason: usageSummary.finishReason }), + ...extra, + }; + + // Let the caller enrich the summary (e.g. add copilotCredits) before + // we create the usage data parts, so VS Code session cost works. + options.onTransportSummary?.(summary); + + options.output?.appendLine( + `[response-summary] status=${String(summary.status ?? "n/a")} durationMs=${String(summary.durationMs)} ttfbMs=${String(summary.ttfbMs ?? "n/a")} promptTokens=${String(summary.promptTokens ?? "n/a")} completionTokens=${String(summary.completionTokens ?? "n/a")} totalTokens=${String(summary.totalTokens ?? "n/a")} cachedTokens=${String(summary.cachedTokens ?? "n/a")} finishReason=${summary.finishReason ?? ""} totalBytes=${String(summary.totalBytes)} totalEvents=${String(summary.totalEvents)}`, + ); + const usageLog = formatUsageLogLine({ + promptTokens: summary.promptTokens, + completionTokens: summary.completionTokens, + totalTokens: summary.totalTokens, + cachedTokens: summary.cachedTokens, + finishReason: summary.finishReason, + }); + if (usageLog) { + options.output?.appendLine(`[usage] ${usageLog}`); + } + + if (localRequestId) { + reportUsageToContextWindowForRequest(localRequestId, { + promptTokens: summary.promptTokens, + completionTokens: summary.completionTokens, + totalTokens: summary.totalTokens, + cachedTokens: summary.cachedTokens, + finishReason: summary.finishReason, + }); + } + + const usageParts = + summary.errorMessage || summary.abortedReason + ? [] + : createUsageDataParts({ + promptTokens: summary.promptTokens, + completionTokens: summary.completionTokens, + totalTokens: summary.totalTokens, + cachedTokens: summary.cachedTokens, + finishReason: summary.finishReason, + copilotCredits: summary.copilotCredits, + }); + for (const usagePart of usageParts) { + reportProgressPart(localRequestId, options.progress, usagePart); + } + }; + + try { + if (localRequestId && options.contextWindowOutputBuffer !== undefined) { + setContextWindowOutputBufferForRequest(localRequestId, options.contextWindowOutputBuffer); + } + + const rawPayload = JSON.stringify(options.body); + + // Log request for debugging latency. + options.output?.appendLine( + `[request] url=${options.url} payloadBytes=${String(rawPayload.length)} requestTimeoutMs=${String(options.requestTimeoutMs)} streamIdleTimeoutMs=${String(options.streamIdleTimeoutMs)}`, + ); + + // ------------------------------------------------------------------ + // NOTE: We do NOT gzip-compress the payload. The OpenCode proxy + // does not support Content-Encoding: gzip and returns HTTP 500. + // ------------------------------------------------------------------ + let payload = rawPayload; + const fetchHeaders: Record = { + ...(options.authHeaders ?? { Authorization: `Bearer ${options.apiKey}` }), + "Content-Type": "application/json", + ...options.requestHeaders, + }; + const fetchWithBody = (body: string) => + fetch(options.url, { + method: "POST", + headers: fetchHeaders, + body, + signal: controller.signal, + }); + + let response = await fetchWithBody(payload); + + // --- Runtime retry for recoverable HTTP 400 errors --- + // If the upstream rejects a parameter or reports an exact context overflow, + // patch the body and retry once. This handles tokenizer differences, stale + // models.dev metadata, and provider API changes without a hard user failure. + let consumedErrorBody: string | undefined; + if (response.status === 400) { + const errorDetail = await response.text(); + consumedErrorBody = errorDetail; + options.output?.appendLine(`[http-error-body] ${errorDetail.trim() ? truncateForLog(errorDetail) : ""}`); + const parsedBody = JSON.parse(rawPayload) as Record; + const patch = analyzeHttp400ForRetry(errorDetail, parsedBody); + if (patch) { + options.output?.appendLine(`[retry] HTTP 400 recoverable: ${patch.reason}. Retrying with patched body…`); + payload = JSON.stringify(patch.body); + response = await fetchWithBody(payload); + options.output?.appendLine(`[retry] Response after patch: ${String(response.status)} ${response.statusText}`); + // If retry also returned 400, consume its body so the normal error + // handler below doesn't try to re-read (the stream is already consumed). + if (!response.ok && response.status === 400) { + consumedErrorBody = await response.text(); + } else { + // The patched retry produced a fresh (non-consumed) body, so any + // stored 400 detail no longer matches the current response. + consumedErrorBody = undefined; + } + } + } + + // --- Transient 5xx retry (gateway/router capacity) --- + // Retry a small number of times with exponential backoff (plus jitter) + // when the gateway is momentarily unavailable (502/503/504, or 5xx body + // that names Router.Unavailable). Cancellation aborts the wait immediately. + let attempt = 0; + while (attempt < TRANSIENT_5XX_MAX_RETRIES && isTransientServerError(response.status, consumedErrorBody ?? "")) { + attempt += 1; + // Jitter spreads concurrent retries so they don't pile on the gateway + // at the same timestamp. + const backoffMs = Math.round(TRANSIENT_5XX_RETRY_BASE_MS * 2 ** (attempt - 1) + Math.random() * TRANSIENT_5XX_RETRY_JITTER_MS); + options.output?.appendLine( + `[retry] transient ${String(response.status)} (attempt ${String(attempt)}/${String(TRANSIENT_5XX_MAX_RETRIES)}); retrying in ${String(backoffMs)}ms…`, + ); + await sleepWithCancellation(backoffMs, options.token); + if (options.token.isCancellationRequested) { + break; + } + response = await fetchWithBody(payload); + // A fresh response may carry a new error body; drop stale 400 detail. + consumedErrorBody = undefined; + } + + responseStatus = response.status; + responseContentType = response.headers.get("content-type") ?? ""; + options.output?.appendLine(`[http] ${String(response.status)} ${response.statusText} content-type=${responseContentType || ""}`); + const rateLimitSummary = formatRateLimitSummary(readRateLimitInfo(response.headers)); + if (rateLimitSummary) { + options.output?.appendLine(`[rate-limit] ${rateLimitSummary}`); + } + + if (!response.ok) { + // Use already-consumed body if available (from retry logic above), + // otherwise read from the response stream. + const detail = consumedErrorBody ?? (await response.text()); + options.output?.appendLine(`[http-error-body] ${detail.trim() ? truncateForLog(detail) : ""}`); + const capacityHint = + options.capacityLimitedModelNotes?.[options.modelId] && response.status >= 500 + ? ` — ${options.capacityLimitedModelNotes[options.modelId]}` + : ""; + const requestError = buildOpenCodeRequestError( + options.providerDisplayName, + response, + detail, + options.modelId, + payload.length, + capacityHint, + ); + emitSummary(new TextEncoder().encode(detail).byteLength, 0, { + errorMessage: requestError.message, + rateLimitSummary, + }); + throw requestError; + } + + if (!response.body || !responseContentType.includes("text/event-stream")) { + const raw = await response.text(); + firstByteAt ??= Date.now(); + options.output?.appendLine(`[non-stream-body] ${truncateForLog(raw)}`); + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + data = undefined; + } + if (data !== undefined) { + updateRequestUsageSummary(usageSummary, data); + for (const part of options.extractFullParts(data)) { + reportProgressPart(localRequestId, options.progress, part); + } + } + emitSummary(new TextEncoder().encode(raw).byteLength, data === undefined ? 0 : 1, { + rateLimitSummary, + }); + return; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let totalBytes = 0; + let totalEvents = 0; + // Diagnostic: collect raw SSE data when response is empty to identify + // format mismatches between gateway output and our extractor (issue #93). + const rawSseData: unknown[] = []; + let extractedPartCount = 0; + resetStreamIdleTimeout(); + + while (!options.token.isCancellationRequested) { + const { value, done } = await reader.read(); + if (done) { + break; + } + resetStreamIdleTimeout(); + + totalBytes += value.byteLength; + if (firstByteAt === undefined && value.byteLength > 0) { + firstByteAt = Date.now(); + } + const chunk = decoder.decode(value, { stream: true }); + if (options.debugReasoning && options.output && chunk) { + options.output.appendLine(`[sse-raw bytes=${String(value.byteLength)}] ${truncateForLog(chunk)}`); + } + buffer += chunk; + const events = buffer.split("\n\n"); + buffer = events.pop() ?? ""; + + for (const event of events) { + totalEvents += 1; + if (options.debugReasoning && options.output && event.trim()) { + options.output.appendLine(`[sse] ${truncateForLog(event)}`); + } + for (const part of parseServerSentEvent(event, options.extractStreamParts, (data) => { + updateRequestUsageSummary(usageSummary, data); + rawSseData.push(data); + })) { + extractedPartCount += 1; + reportProgressPart(localRequestId, options.progress, part); + } + } + } + + if (buffer.trim()) { + if (options.debugReasoning && options.output) { + options.output.appendLine(`[sse-tail] ${truncateForLog(buffer)}`); + } + for (const part of parseServerSentEvent(buffer, options.extractStreamParts, (data) => { + updateRequestUsageSummary(usageSummary, data); + rawSseData.push(data); + })) { + extractedPartCount += 1; + reportProgressPart(localRequestId, options.progress, part); + } + } + + if (options.debugReasoning && options.output) { + options.output.appendLine( + `[sse-stats] totalBytes=${String(totalBytes)} totalEvents=${String(totalEvents)} bufferTailLen=${String(buffer.length)}`, + ); + } + + // Diagnostic: when the gateway reported completion tokens but our + // extractor found nothing, dump raw SSE data to identify format mismatches. + // This helps diagnose issues like #93 where the model generates tokens + // but the response content is in an unrecognized format. + if (usageSummary.completionTokens && usageSummary.completionTokens > 0 && extractedPartCount === 0 && rawSseData.length > 0) { + options.output?.appendLine( + `[diag-empty-response] model=${options.modelId} completionTokens=${String(usageSummary.completionTokens)} totalEvents=${String(totalEvents)} rawSseDataCount=${String(rawSseData.length)}`, + ); + for (let i = 0; i < rawSseData.length; i++) { + options.output?.appendLine(`[diag-sse-event-${String(i)}] ${truncateForLog(JSON.stringify(rawSseData[i]))}`); + } + } + + emitSummary(totalBytes, totalEvents, { rateLimitSummary }); + } catch (error) { + if (abortReason === "cancelled") { + emitSummary(0, 0, { + abortedReason: "cancelled", + errorMessage: "request cancelled", + }); + return; + } + if (abortReason === "request-timeout") { + const requestError = new OpenCodeRequestError( + `${options.providerDisplayName} request timed out after ${formatDuration(options.requestTimeoutMs)}.`, + `${options.providerDisplayName} did not start or finish the request within ${formatDuration(options.requestTimeoutMs)}. Try again later or reduce the request size.`, + ); + emitSummary(0, 0, { + abortedReason: "request-timeout", + errorMessage: requestError.message, + }); + throw requestError; + } + if (abortReason === "stream-idle-timeout") { + const requestError = new OpenCodeRequestError( + `${options.providerDisplayName} stream stalled for ${formatDuration(options.streamIdleTimeoutMs)} without new data.`, + `${options.providerDisplayName} stopped sending stream data for ${formatDuration(options.streamIdleTimeoutMs)}, so the request was cancelled to avoid leaving Copilot stuck.`, + ); + emitSummary(0, 0, { + abortedReason: "stream-idle-timeout", + errorMessage: requestError.message, + }); + throw requestError; + } + emitSummary(0, 0, { + errorMessage: getErrorMessage(error), + }); + throw error; + } finally { + clearTimeout(requestTimeout); + if (streamIdleTimeout) { + clearTimeout(streamIdleTimeout); + } + cancellation.dispose(); + if (localRequestId) { + clearContextWindowRequest(localRequestId); + } + } +} + +export function createReasoningDebugger( + output: vscode.OutputChannel | undefined, + enabled: boolean, +): ((reasoningContent: string) => void) | undefined { + if (!enabled || !output) { + return undefined; + } + + return (reasoningContent) => { + output.appendLine("[reasoning_content]"); + output.appendLine(reasoningContent); + output.appendLine("[/reasoning_content]"); + }; +} diff --git a/src/transports/extract.ts b/src/transports/extract.ts new file mode 100644 index 0000000..69bfe85 --- /dev/null +++ b/src/transports/extract.ts @@ -0,0 +1,225 @@ +import * as vscode from "vscode"; +import { parseToolInput } from "../toolCallAccumulator"; +import { isRecord } from "../utils"; +import { thinkingPartConstructor, type RequestUsageSummary } from "./streamParts"; + +/** Non-stream chat-completions extraction (full response, not SSE). */ +function extractChatCompletionParts(data: unknown): vscode.LanguageModelResponsePart[] { + if (!isRecord(data) || !Array.isArray(data.choices)) { + return []; + } + + const first: unknown = data.choices[0]; + if (!isRecord(first)) { + return []; + } + + const parts: vscode.LanguageModelResponsePart[] = []; + const message = first.message; + if (isRecord(message)) { + const text = extractTextFromDelta(message); + if (text) { + parts.push(new vscode.LanguageModelTextPart(text)); + } else { + const reasoning = extractReasoningFromDelta(message); + if (reasoning.trim()) { + // Non-stream path: emit reasoning via thinking part when the API is + // available (so chat.agent.thinkingStyle applies), else fall back to + // plain text. Cast needed because LanguageModelThinkingPart is in the + // LanguageModelResponsePart2 union, not the stable LanguageModelResponsePart. + const thinkingPart = thinkingPartConstructor + ? (new thinkingPartConstructor(reasoning) as unknown as vscode.LanguageModelResponsePart) + : new vscode.LanguageModelTextPart(reasoning); + parts.push(thinkingPart); + } + } + for (const toolCallPart of toolCallPartsFromOpenAiMessage(message.tool_calls)) { + parts.push(toolCallPart); + } + } + + if (typeof first.text === "string") { + parts.push(new vscode.LanguageModelTextPart(first.text)); + } + + return parts; +} + +/** Pure: collect text from an OpenAI-style delta/message object. */ +export function extractTextFromDelta(delta: Record): string { + const candidates: unknown[] = [delta.content, delta.text, delta.output_text]; + let collected = ""; + for (const candidate of candidates) { + if (typeof candidate === "string" && candidate.length > 0) { + collected += candidate; + continue; + } + if (Array.isArray(candidate)) { + for (const part of candidate) { + if (typeof part === "string") { + collected += part; + } else if (isRecord(part)) { + const text = part.text ?? part.value ?? part.output_text; + if (typeof text === "string") { + collected += text; + } + } + } + } + } + return collected; +} + +/** Pure: collect reasoning from an OpenAI-style delta/message object. */ +export function extractReasoningFromDelta(delta: Record): string { + const candidates: unknown[] = [ + delta.reasoning_content, + delta.reasoning, + delta.thinking, + isRecord(delta.message) ? delta.message.reasoning_content : undefined, + ]; + let collected = ""; + for (const candidate of candidates) { + if (typeof candidate === "string") { + collected += candidate; + } else if (isRecord(candidate) && typeof candidate.content === "string") { + collected += candidate.content; + } else if (Array.isArray(candidate)) { + for (const part of candidate) { + if (typeof part === "string") { + collected += part; + } else if (isRecord(part) && typeof part.text === "string") { + collected += part.text; + } + } + } + } + return collected; +} + +/** Non-stream Anthropic messages extraction. */ +function extractAnthropicParts(data: unknown): vscode.LanguageModelResponsePart[] { + if (!isRecord(data) || !Array.isArray(data.content)) { + return []; + } + + const parts: vscode.LanguageModelResponsePart[] = []; + const textParts: string[] = []; + const reasoningParts: string[] = []; + + for (const block of data.content) { + if (!isRecord(block)) { + continue; + } + + if (typeof block.text === "string" && block.text.length > 0) { + textParts.push(block.text); + continue; + } + + // Anthropic thinking blocks — surface via thinking part when available. + if ( + (block.type === "thinking" || block.type === "redacted_thinking") && + typeof block.thinking === "string" && + block.thinking.length > 0 + ) { + reasoningParts.push(block.thinking); + continue; + } + + if (block.type === "tool_use" && typeof block.name === "string") { + const id = typeof block.id === "string" ? block.id : `opencodego-tool-${String(Date.now())}`; + const input = isRecord(block.input) ? block.input : parseToolInput(typeof block.input === "string" ? block.input : "{}"); + parts.push(new vscode.LanguageModelToolCallPart(id, block.name, input)); + } + } + + const text = textParts.join(""); + if (text) { + parts.unshift(new vscode.LanguageModelTextPart(text)); + } + + // Emit accumulated reasoning via thinking part (or text fallback) at the front. + const reasoning = reasoningParts.join(""); + if (reasoning) { + const thinkingPart = thinkingPartConstructor + ? (new thinkingPartConstructor(reasoning) as unknown as vscode.LanguageModelResponsePart) + : new vscode.LanguageModelTextPart(reasoning); + parts.unshift(thinkingPart); + } + + return parts; +} + +/** Pure: build tool-call parts from an OpenAI-style `tool_calls` array. */ +function toolCallPartsFromOpenAiMessage(toolCalls: unknown): vscode.LanguageModelToolCallPart[] { + if (!Array.isArray(toolCalls)) { + return []; + } + + return toolCalls + .filter(isRecord) + .map((toolCall, index) => { + const fn = toolCall.function; + const id = typeof toolCall.id === "string" ? toolCall.id : `opencodego-tool-${String(Date.now())}-${String(index)}`; + const name = isRecord(fn) && typeof fn.name === "string" ? fn.name : ""; + const args = isRecord(fn) && typeof fn.arguments === "string" ? fn.arguments : "{}"; + return name ? new vscode.LanguageModelToolCallPart(id, name, parseToolInput(args)) : undefined; + }) + .filter((part): part is vscode.LanguageModelToolCallPart => Boolean(part)); +} + +/** Accumulate usage/stop-reason fields from a parsed SSE/full payload. */ +export function updateRequestUsageSummary(summary: RequestUsageSummary, data: unknown): void { + if (!isRecord(data)) { + return; + } + + const usage = isRecord(data.usage) ? data.usage : undefined; + if (usage) { + // OpenAI-compatible fields + const promptTokens = typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : undefined; + const completionTokens = typeof usage.completion_tokens === "number" ? usage.completion_tokens : undefined; + const totalTokens = typeof usage.total_tokens === "number" ? usage.total_tokens : undefined; + const promptTokenDetails = isRecord(usage.prompt_tokens_details) ? usage.prompt_tokens_details : undefined; + const cachedTokens = + promptTokenDetails && typeof promptTokenDetails.cached_tokens === "number" ? promptTokenDetails.cached_tokens : undefined; + + // Anthropic-compatible fields (input_tokens / output_tokens) + const anthropicInputTokens = typeof usage.input_tokens === "number" ? usage.input_tokens : undefined; + const anthropicOutputTokens = typeof usage.output_tokens === "number" ? usage.output_tokens : undefined; + const cacheReadInputTokens = typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined; + + if (promptTokens !== undefined) { + summary.promptTokens = promptTokens; + } else if (anthropicInputTokens !== undefined) { + summary.promptTokens = anthropicInputTokens; + } + if (completionTokens !== undefined) { + summary.completionTokens = completionTokens; + } else if (anthropicOutputTokens !== undefined) { + summary.completionTokens = anthropicOutputTokens; + } + if (totalTokens !== undefined) { + summary.totalTokens = totalTokens; + } + if (cachedTokens !== undefined) { + summary.cachedTokens = cachedTokens; + } else if (cacheReadInputTokens !== undefined) { + summary.cachedTokens = cacheReadInputTokens; + } + } + + // Anthropic message_delta reports stop_reason in delta, not in choices + const delta = isRecord(data.delta) ? data.delta : undefined; + if (delta && typeof delta.stop_reason === "string") { + summary.finishReason = delta.stop_reason; + } + + const firstChoice = Array.isArray(data.choices) && isRecord(data.choices[0]) ? data.choices[0] : undefined; + if (firstChoice && typeof firstChoice.finish_reason === "string") { + summary.finishReason = firstChoice.finish_reason; + } +} + +export { extractChatCompletionParts, extractAnthropicParts, toolCallPartsFromOpenAiMessage }; diff --git a/src/transports/extractors.ts b/src/transports/extractors.ts new file mode 100644 index 0000000..11df68c --- /dev/null +++ b/src/transports/extractors.ts @@ -0,0 +1,556 @@ +import * as vscode from "vscode"; +import { createReasoningMarkerPart } from "../chatParts"; +import { parseToolInput, ToolCallAccumulator, type PendingToolCall } from "../toolCallAccumulator"; +import { isRecord } from "../utils"; +import type { ThinkTagFilter } from "./thinkTags"; +import { emitThinkingPart, reportProgressPart, thinkingPartConstructor } from "./streamParts"; +import { extractReasoningFromDelta, extractTextFromDelta } from "./extract"; + +/** + * Shared state + behavior for the OpenAI- and Anthropic-style stream + * extractors: text/reasoning accounting, think-tag filtering, live reasoning + * emission, and the end-of-stream reasoning fallback. + */ +abstract class BaseResponseExtractor { + protected reasoningContent = ""; + protected emittedTextLength = 0; + protected emittedToolCallsCount = 0; + /** + * Total reasoning characters seen across the entire stream (monotonic). + * Unlike `reasoningContent` (cleared by tool-call flushes), this counter is + * used for the [stream-summary] log line so metrics stay accurate. + */ + protected totalReasoningChars = 0; + + constructor( + protected readonly onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void, + protected readonly onReasoningDebug?: (reasoningContent: string) => void, + protected readonly thinkFilter?: ThinkTagFilter, + protected readonly progress?: vscode.Progress, + protected readonly localRequestId?: string, + protected readonly output?: vscode.OutputChannel, + ) {} + + get emittedText(): number { + return this.emittedTextLength; + } + + get emittedTools(): number { + return this.emittedToolCallsCount; + } + + get reasoningChars(): number { + return this.totalReasoningChars; + } + + /** Split text through the think-tag filter (if active). */ + protected filterText(text: string): { visible: string; thinking: string } { + if (!text) { + return { visible: "", thinking: "" }; + } + if (!this.thinkFilter) { + return { visible: text, thinking: "" }; + } + return this.thinkFilter.process(text); + } + + /** + * Accumulate reasoning for tool-call replication and — when the thinking + * part API is available — stream it live to the Copilot Chat UI as + * `LanguageModelThinkingPart` so `chat.agent.thinkingStyle` applies. + */ + protected handleReasoning(reasoning: string): string { + if (!reasoning) { + return ""; + } + this.reasoningContent += reasoning; + this.totalReasoningChars += reasoning.length; + if (this.progress) { + emitThinkingPart(this.localRequestId, this.progress, reasoning); + } + return reasoning; + } + + /** Emit the shared end-of-stream reasoning fallback. */ + flushReasoningFallback(progress: vscode.Progress, localRequestId?: string): void { + // Flush any remaining text in the think filter + if (this.thinkFilter) { + const { visible, thinking } = this.thinkFilter.finish(); + if (visible) { + this.emittedTextLength += visible.length; + reportProgressPart(localRequestId, progress, new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + // Surface remaining think-filter carry through the thinking part channel. + this.handleReasoning(thinking); + } + } + + const reasoning = this.reasoningContent.trim(); + if (!reasoning) { + return; + } + // If the thinking part API is available, reasoning was already streamed + // live during extractStreamParts via handleReasoning(). The accumulated + // reasoningContent is retained only for tool-call replication + // (flushToolCalls → onReasoningContent). Nothing more to emit here. + if (thinkingPartConstructor) { + this.reasoningContent = ""; + return; + } + // Legacy fallback (API unavailable): emit reasoning as plain text only + // when the response is otherwise empty, to avoid breaking the visible + // output. This preserves the pre-fix safety-net semantics. + if (this.emittedTextLength > 0 || this.emittedToolCallsCount > 0) { + this.reasoningContent = ""; + return; + } + this.onReasoningDebug?.(this.reasoningContent); + reportProgressPart(localRequestId, progress, new vscode.LanguageModelTextPart(reasoning)); + this.emittedTextLength += reasoning.length; + this.reasoningContent = ""; + } +} + +class OpenAiResponseExtractor extends BaseResponseExtractor { + private readonly toolCallAccumulator = new ToolCallAccumulator(); + /** + * 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; + /** Reasoning emitted as visible text (gateway bug #37635, thinking OFF). */ + private reasoningAsContent = ""; + + constructor( + onReasoningContent?: (toolCallIds: string[], reasoningContent: string) => void, + onReasoningDebug?: (reasoningContent: string) => void, + thinkFilter?: ThinkTagFilter, + progress?: vscode.Progress, + localRequestId?: string, + output?: vscode.OutputChannel, + /** + * Display seam: whether `reasoning_content` should be emitted as a visible + * `LanguageModelTextPart` instead of a thinking part. + * + * Computed upstream by the thinking provider strategy. Currently always + * false — reasoning models emit genuine CoT in `reasoning_content`, so it + * goes to the thinking panel. (The old gateway #37635 mislabel is not + * worked around.) + */ + private readonly treatReasoningAsContent = false, + ) { + super(onReasoningContent, onReasoningDebug, thinkFilter, progress, localRequestId, output); + } + + /** Whether the reasoning loop suppression was triggered. */ + get reasoningLoopSuppressed(): boolean { + return this._reasoningLoopSuppressed; + } + + /** + * Emit an internal marker part carrying the reasoning that was surfaced as + * visible text, so the next turn can echo it back as reasoning_content. + * Called after the stream completes; the transcript keeps the marker. + */ + flushReasoningMarker(): vscode.LanguageModelResponsePart2 | undefined { + if (!this.treatReasoningAsContent || !this.reasoningAsContent) { + return undefined; + } + const part = createReasoningMarkerPart(this.reasoningAsContent); + this.reasoningAsContent = ""; + return part; + } + + /** + * 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 the same 40-char suffix repeats across + * 6+ consecutive chunks, the model is stuck and further thinking parts are + * suppressed (accumulation continues for tool-call replication). + */ + override handleReasoning(reasoning: string): string { + if (!reasoning) { + return ""; + } + this.reasoningContent += reasoning; + this.totalReasoningChars += reasoning.length; + + 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. + if (this.progress) { + emitThinkingPart(this.localRequestId, this.progress, reasoning); + } + 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. + */ + 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 >= OpenAiResponseExtractor.REASONING_LOOP_SUFFIX_MATCHES) { + 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 []; + } + + const first: unknown = data.choices[0]; + if (!isRecord(first)) { + return []; + } + + const parts: vscode.LanguageModelResponsePart[] = []; + const delta = first.delta; + if (isRecord(delta)) { + const text = extractTextFromDelta(delta); + const { visible, thinking } = this.filterText(text); + if (visible) { + this.emittedTextLength += visible.length; + parts.push(new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + const reasoning = extractReasoningFromDelta(delta); + if (reasoning) { + // Dormant display seam: were treatReasoningAsContent true AND + // delta.content empty, reasoning_content would be emitted as visible + // text (old gateway #37635 mislabel). Never set today — reasoning is + // genuine CoT and goes to the thinking panel. Loop guard still applies. + if (this.treatReasoningAsContent && !visible && text.length === 0) { + if (!this.shouldSuppressThinkingEmit(reasoning)) { + this.emittedTextLength += reasoning.length; + this.reasoningAsContent += reasoning; + parts.push(new vscode.LanguageModelTextPart(reasoning)); + } + } else { + this.handleReasoning(reasoning); + } + } + this.collectOpenAiToolCalls(delta.tool_calls); + } + + const message = first.message; + if (isRecord(message)) { + const text = extractTextFromDelta(message); + const { visible, thinking } = this.filterText(text); + if (visible) { + this.emittedTextLength += visible.length; + parts.push(new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + const reasoning = extractReasoningFromDelta(message); + if (reasoning) { + this.handleReasoning(reasoning); + } + this.collectOpenAiToolCalls(message.tool_calls); + } + + // Flush accumulated tool calls ONLY when the stream reports the OpenAI + // `"tool_calls"` finish reason. Intermediate chunks always carry + // `finish_reason: null`, so flushing there would emit an incomplete tool + // call (empty arguments → `` without ``, issue #98). + // Gateways that omit `finish_reason` entirely (e.g. OpenCode Go for + // gpt-5.6-luna, issue #93) are flushed once at end-of-stream via + // `flushRemainingToolCalls()`. + if (ToolCallAccumulator.shouldFlushOnFinishReason(first.finish_reason)) { + const toolParts = this.flushToolCalls(); + this.emittedToolCallsCount += toolParts.length; + parts.push(...toolParts); + } + + return parts; + } + + override flushReasoningFallback(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; + } + super.flushReasoningFallback(progress, localRequestId); + } + + private collectOpenAiToolCalls(toolCalls: unknown): void { + this.toolCallAccumulator.collect(toolCalls); + } + + private flushToolCalls(): vscode.LanguageModelToolCallPart[] { + const calls = this.toolCallAccumulator.flush(); + const parts = calls.map( + (call, index) => + new vscode.LanguageModelToolCallPart(call.id || `opencodego-tool-${String(Date.now())}-${String(index)}`, call.name, call.input), + ); + + if (this.reasoningContent.trim()) { + this.onReasoningDebug?.(this.reasoningContent); + this.onReasoningContent?.( + parts.map((part) => part.callId), + this.reasoningContent, + ); + } + + this.reasoningContent = ""; + return parts; + } + + /** + * Flush any tool calls still accumulated when the stream ends. Some + * gateways omit the `finish_reason: "tool_calls"` final event (e.g. the + * OpenCode Go gateway for gpt-5.6-luna, issue #93), so a final flush here + * prevents those calls from silently disappearing. + * + * Must be called BEFORE `flushReasoningFallback` so tool-call reasoning + * replication runs first. Safe no-op when nothing is pending. + */ + flushRemainingToolCalls(progress: vscode.Progress, localRequestId?: string): void { + if (this.toolCallAccumulator.size === 0) { + return; + } + const toolParts = this.flushToolCalls(); + this.emittedToolCallsCount += toolParts.length; + for (const part of toolParts) { + reportProgressPart(localRequestId, progress, part); + } + } +} + +class AnthropicResponseExtractor extends BaseResponseExtractor { + private readonly pendingToolCalls = new Map(); + + extractStreamParts(data: unknown): vscode.LanguageModelResponsePart[] { + if (!isRecord(data)) { + return []; + } + + const parts: vscode.LanguageModelResponsePart[] = []; + const eventType = typeof data.type === "string" ? data.type : ""; + const delta = isRecord(data.delta) ? data.delta : undefined; + + // --- Handle Anthropic SSE event types --- + + // 1. content_block_start: contains the initial content block info. + // For tool_use blocks, the id and name are in data.content_block. + // For text blocks, data.content_block.text may contain initial text. + if (eventType === "content_block_start") { + const contentBlock = isRecord(data.content_block) ? data.content_block : undefined; + const index = typeof data.index === "number" ? data.index : this.pendingToolCalls.size; + + if (contentBlock && contentBlock.type === "tool_use") { + const pending = this.pendingToolCalls.get(index) ?? { + id: "", + name: "", + arguments: "", + }; + if (typeof contentBlock.id === "string") { + pending.id = contentBlock.id; + } + if (typeof contentBlock.name === "string") { + pending.name += contentBlock.name; + } + this.pendingToolCalls.set(index, pending); + } else if (contentBlock && contentBlock.type === "thinking" && typeof contentBlock.thinking === "string") { + this.handleReasoning(contentBlock.thinking); + } else if (contentBlock && typeof contentBlock.text === "string" && contentBlock.text.length > 0) { + const { visible, thinking } = this.filterText(contentBlock.text); + if (visible) { + this.emittedTextLength += visible.length; + parts.push(new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + } + + return parts; + } + + // 2. content_block_delta: streaming deltas for the current block. + // text delta: delta.type === "text_delta", delta.text + // thinking delta: delta.type === "thinking_delta", delta.thinking + // tool input delta: delta.type === "input_json_delta", delta.partial_json + if (eventType === "content_block_delta" && delta) { + if (delta.type === "text_delta" && typeof delta.text === "string" && delta.text.length > 0) { + const { visible, thinking } = this.filterText(delta.text); + if (visible) { + this.emittedTextLength += visible.length; + parts.push(new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + } else if (delta.type === "thinking_delta" && typeof delta.thinking === "string" && delta.thinking.length > 0) { + this.handleReasoning(delta.thinking); + } else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") { + const index = typeof data.index === "number" ? data.index : this.pendingToolCalls.size - 1; + const pending = this.pendingToolCalls.get(index) ?? { + id: "", + name: "", + arguments: "", + }; + pending.arguments += delta.partial_json; + this.pendingToolCalls.set(index, pending); + } + + return parts; + } + + // 3. message_delta: contains stop_reason and usage. Usage is already + // collected via the onData callback in parseServerSentEvent (which + // updates the real RequestUsageSummary); here we only flush tool calls. + if (eventType === "message_delta" && delta) { + if (delta.stop_reason) { + const toolParts = this.flushToolCalls(); + this.emittedToolCallsCount += toolParts.length; + parts.push(...toolParts); + } + return parts; + } + + // 4. message_stop: final event, flush any remaining tool calls. + if (eventType === "message_stop") { + const toolParts = this.flushToolCalls(); + this.emittedToolCallsCount += toolParts.length; + parts.push(...toolParts); + return parts; + } + + // --- Fallback: handle non-standard or flat SSE shapes --- + // Some providers may send Anthropic-style data without explicit event types, + // or use a flat delta shape similar to the original extractor logic. + if (delta) { + if (typeof delta.text === "string" && delta.text.length > 0) { + const { visible, thinking } = this.filterText(delta.text); + if (visible) { + this.emittedTextLength += visible.length; + parts.push(new vscode.LanguageModelTextPart(visible)); + } + if (thinking) { + this.handleReasoning(thinking); + } + } + + if (typeof delta.thinking === "string" && delta.thinking.length > 0) { + this.handleReasoning(delta.thinking); + } + if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) { + this.handleReasoning(delta.reasoning_content); + } + if (typeof delta.reasoning === "string" && delta.reasoning.length > 0) { + this.handleReasoning(delta.reasoning); + } + + if (typeof delta.type === "string") { + // Flat tool_use delta (non-standard but some gateways use this) + if (delta.type === "tool_use") { + const index = typeof delta.index === "number" ? delta.index : this.pendingToolCalls.size; + const pending = this.pendingToolCalls.get(index) ?? { + id: "", + name: "", + arguments: "", + }; + if (typeof delta.id === "string") { + pending.id = delta.id; + } + if (typeof delta.name === "string") { + pending.name += delta.name; + } + if (typeof delta.input === "string") { + pending.arguments += delta.input; + } else if (isRecord(delta.input)) { + pending.arguments += JSON.stringify(delta.input); + } + this.pendingToolCalls.set(index, pending); + } + } + + if (delta.stop_reason) { + const toolParts = this.flushToolCalls(); + this.emittedToolCallsCount += toolParts.length; + parts.push(...toolParts); + } + } + + return parts; + } + + private flushToolCalls(): vscode.LanguageModelToolCallPart[] { + const toolCalls = Array.from(this.pendingToolCalls.values()).filter((toolCall) => toolCall.name); + const parts = toolCalls.map( + (toolCall, index) => + new vscode.LanguageModelToolCallPart( + toolCall.id || `opencodego-tool-${String(Date.now())}-${String(index)}`, + toolCall.name, + parseToolInput(toolCall.arguments), + ), + ); + + if (this.reasoningContent.trim()) { + this.onReasoningDebug?.(this.reasoningContent); + this.onReasoningContent?.( + parts.map((part) => part.callId), + this.reasoningContent, + ); + } + + this.pendingToolCalls.clear(); + this.reasoningContent = ""; + return parts; + } +} + +export { BaseResponseExtractor, OpenAiResponseExtractor, AnthropicResponseExtractor }; diff --git a/src/transports/google.ts b/src/transports/google.ts new file mode 100644 index 0000000..722c201 --- /dev/null +++ b/src/transports/google.ts @@ -0,0 +1,31 @@ +import type { StreamRequestOptions } from "../core/transport"; +import { normalizeGoogleFullResponse, normalizeGoogleStreamEvent } from "../routing"; +import { createThinkTagFilter } from "./thinkTags"; +import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; +import { OpenAiResponseExtractor } from "./extractors"; +import { extractChatCompletionParts } from "./extract"; + +/** Google Generative Language API transport (Gemini via Zen). */ +export async function streamGoogleGenerateContent(options: StreamRequestOptions): Promise { + const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); + const extractor = new OpenAiResponseExtractor( + options.onReasoningContent, + createReasoningDebugger(options.output, options.debugReasoning), + thinkFilter, + options.progress, + options.requestHeaders["x-opencode-request"], + ); + + await streamOpenCodeResponse({ + ...options, + url: `${options.url}:streamGenerateContent?alt=sse`, + extractStreamParts: (data) => extractor.extractStreamParts(normalizeGoogleStreamEvent(data)), + extractFullParts: (data) => extractChatCompletionParts(normalizeGoogleFullResponse(data)), + }); + + extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); + extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); + options.output?.appendLine( + `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, + ); +} diff --git a/src/transports/responses.ts b/src/transports/responses.ts new file mode 100644 index 0000000..ed3f6e2 --- /dev/null +++ b/src/transports/responses.ts @@ -0,0 +1,30 @@ +import type { StreamRequestOptions } from "../core/transport"; +import { normalizeResponsesFullResponse, normalizeResponsesStreamEvent } from "../routing"; +import { createThinkTagFilter } from "./thinkTags"; +import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; +import { OpenAiResponseExtractor } from "./extractors"; +import { extractChatCompletionParts } from "./extract"; + +/** OpenAI Responses API transport (GPT-family models). */ +export async function streamResponsesApi(options: StreamRequestOptions): Promise { + const thinkFilter = createThinkTagFilter(options.stripThinkTags, options.modelId); + const extractor = new OpenAiResponseExtractor( + options.onReasoningContent, + createReasoningDebugger(options.output, options.debugReasoning), + thinkFilter, + options.progress, + options.requestHeaders["x-opencode-request"], + ); + + await streamOpenCodeResponse({ + ...options, + extractStreamParts: (data) => extractor.extractStreamParts(normalizeResponsesStreamEvent(data)), + extractFullParts: (data) => extractChatCompletionParts(normalizeResponsesFullResponse(data)), + }); + + extractor.flushRemainingToolCalls(options.progress, options.requestHeaders["x-opencode-request"]); + extractor.flushReasoningFallback(options.progress, options.requestHeaders["x-opencode-request"]); + options.output?.appendLine( + `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, + ); +} diff --git a/src/transports/sse.ts b/src/transports/sse.ts new file mode 100644 index 0000000..caf93ca --- /dev/null +++ b/src/transports/sse.ts @@ -0,0 +1,31 @@ +import type * as vscode from "vscode"; + +/** Pure SSE `data:` line parser — one event string in, extracted parts out. */ +export function parseServerSentEvent( + event: string, + extractParts: (data: unknown) => vscode.LanguageModelResponsePart[], + onData?: (data: unknown) => void, +): vscode.LanguageModelResponsePart[] { + const lines = event + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()); + + const parts: vscode.LanguageModelResponsePart[] = []; + + for (const line of lines) { + if (!line || line === "[DONE]") { + continue; + } + + try { + const data = JSON.parse(line) as unknown; + onData?.(data); + parts.push(...extractParts(data)); + } catch { + // Ignore malformed SSE lines; the API may send comments or keep-alive frames. + } + } + + return parts; +} diff --git a/src/transports/streamParts.ts b/src/transports/streamParts.ts new file mode 100644 index 0000000..d0f9ea4 --- /dev/null +++ b/src/transports/streamParts.ts @@ -0,0 +1,88 @@ +import * as vscode from "vscode"; +import { reportProgressWithContextWindowRequest } from "../contextWindowHookBridge"; +import type { StreamRequestOptions } from "../core/transport"; + +interface StreamOpenCodeResponseOptions extends StreamRequestOptions { + extractStreamParts: (data: unknown) => vscode.LanguageModelResponsePart[]; + extractFullParts: (data: unknown) => vscode.LanguageModelResponsePart[]; +} + +interface RequestUsageSummary { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + cachedTokens?: number; + finishReason?: string; + copilotCredits?: number; +} + +function reportProgressPart( + localRequestId: string | undefined, + progress: vscode.Progress, + part: vscode.LanguageModelResponsePart2, +): void { + if (!localRequestId) { + progress.report(part); + return; + } + + reportProgressWithContextWindowRequest(localRequestId, progress, part); +} + +/** + * CONTRACT — Reasoning surfacing via LanguageModelThinkingPart + * + * RULES: + * 1. `LanguageModelThinkingPart` is a proposed VS Code API available at + * runtime since VS Code ~1.102 (Aug 2025). Our `engines.vscode: ^1.125.0` + * guarantees it is present, but we guard defensively so the extension + * degrades gracefully on any hypothetical older host. + * 2. When available, reasoning is streamed to the Copilot Chat UI per-chunk + * as a thinking part. This lets `chat.agent.thinkingStyle` + * (`collapsed` / `collapsedPreview` / `fixedScrolling`) apply, fixing + * issues #22 and #71. + * 3. When NOT available (very old host), the caller falls back to the + * legacy accumulate-and-flush behavior (reasoning emitted as a + * LanguageModelTextPart only when the response is otherwise empty). + * + * INVARIANTS: + * - Never throws: if the constructor is missing or `progress.report` fails, + * the reasoning is silently dropped (the visible response is unaffected). + * - The returned boolean tells the caller whether the thinking part was + * successfully emitted, so the caller can decide whether to also + * accumulate into `reasoningContent` for the legacy fallback path. + */ +const thinkingPartConstructor: (new (value: string | string[]) => vscode.LanguageModelResponsePart2) | undefined = (() => { + const ctor = ( + vscode as unknown as { + LanguageModelThinkingPart?: unknown; + } + ).LanguageModelThinkingPart; + return typeof ctor === "function" ? (ctor as new (value: string | string[]) => vscode.LanguageModelResponsePart2) : undefined; +})(); + +/** + * Emit a reasoning chunk to the Copilot Chat UI as a thinking part. + * + * @returns `true` if the thinking part was emitted successfully; + * `false` if the API is unavailable (caller should accumulate + * for the legacy fallback path). + */ +function emitThinkingPart( + localRequestId: string | undefined, + progress: vscode.Progress, + reasoningChunk: string, +): boolean { + if (!reasoningChunk || !thinkingPartConstructor) { + return false; + } + try { + reportProgressPart(localRequestId, progress, new thinkingPartConstructor(reasoningChunk)); + return true; + } catch { + // Defensive: never let a thinking-part emit failure break the visible response. + return false; + } +} + +export { StreamOpenCodeResponseOptions, RequestUsageSummary, reportProgressPart, thinkingPartConstructor, emitThinkingPart }; diff --git a/src/transports/thinkTags.ts b/src/transports/thinkTags.ts new file mode 100644 index 0000000..784bf1f --- /dev/null +++ b/src/transports/thinkTags.ts @@ -0,0 +1,139 @@ +// --------------------------------------------------------------------------- +// ThinkTagFilter — streaming stripper for inline `...` tags +// +// Some models (notably MiniMax M-series) inline their chain-of-thought +// directly inside the `content` text field wrapped in `` / `` +// tags rather than using a dedicated `reasoning_content` field. When this +// raw text is emitted to the VS Code chat UI the reasoning "leaks" into the +// visible response, making it unreadable. +// +// The filter processes text **as it arrives** (potentially split across many +// SSE chunks) and separates it into: +// • `visibleText` — content outside think tags (emitted to chat) +// • `thinkingText` — content inside think tags (accumulated as reasoning) +// +// Edge cases handled: +// - `` or `` split across chunk boundaries +// - Unclosed `` at end of stream (flushed as thinking on `finish()`) +// - Leading whitespace immediately after opening `` is trimmed +// --------------------------------------------------------------------------- + +const OPEN_THINK_TAG = ""; +const CLOSE_THINK_TAG = ""; + +export function shouldStripThinkTags(mode: "never" | "auto" | "always" | undefined, modelId: string): boolean { + if (mode === "always") { + return true; + } + if (mode === "never" || mode === undefined) { + return false; + } + // "auto" — strip only for models known to inline thinking tags + return /^minimax-m/i.test(modelId); +} + +export function createThinkTagFilter(mode: "never" | "auto" | "always" | undefined, modelId: string): ThinkTagFilter | undefined { + return shouldStripThinkTags(mode, modelId) ? new ThinkTagFilter() : undefined; +} + +export class ThinkTagFilter { + /** Partial text carried over from the previous chunk for boundary matching. */ + private carry = ""; + /** Whether we are currently inside a `` block. */ + private insideThink = false; + + /** + * Process an incoming text chunk. + * Returns `{ visible, thinking }` where `visible` is safe to emit to the + * chat and `thinking` should be accumulated as reasoning content. + */ + process(chunk: string): { visible: string; thinking: string } { + if (!chunk) { + return { visible: "", thinking: "" }; + } + + // Prepend carry from the previous chunk so boundary tags can be detected + // even when they are split across chunks. + const buffer = this.carry + chunk; + this.carry = ""; + + let visible = ""; + let thinking = ""; + let pos = 0; + const maxScan = Math.max(OPEN_THINK_TAG.length, CLOSE_THINK_TAG.length); + + while (pos < buffer.length) { + if (this.insideThink) { + // Look for closing + const closeIdx = buffer.indexOf(CLOSE_THINK_TAG, pos); + if (closeIdx === -1) { + // No closing tag found — consume the rest, but keep a tail for + // boundary matching in the next chunk. + const safeEnd = buffer.length - maxScan; + if (safeEnd > pos) { + thinking += buffer.slice(pos, safeEnd); + this.carry = buffer.slice(safeEnd); + } else { + // Entire remaining buffer is shorter than max scan — carry it all + this.carry = buffer.slice(pos); + } + break; + } + // Found closing tag + thinking += buffer.slice(pos, closeIdx); + pos = closeIdx + CLOSE_THINK_TAG.length; + this.insideThink = false; + // Skip a single leading whitespace after for cleaner output + if (pos < buffer.length && (buffer[pos] === "\n" || buffer[pos] === "\r")) { + pos += 1; + if (pos < buffer.length && buffer[pos] === "\n") { + pos += 1; + } + } + } else { + // Look for opening + const openIdx = buffer.indexOf(OPEN_THINK_TAG, pos); + if (openIdx === -1) { + // No opening tag — emit visible text but keep a tail for boundary + const safeEnd = buffer.length - maxScan; + if (safeEnd > pos) { + visible += buffer.slice(pos, safeEnd); + this.carry = buffer.slice(safeEnd); + } else { + this.carry = buffer.slice(pos); + } + break; + } + // Found opening tag + visible += buffer.slice(pos, openIdx); + pos = openIdx + OPEN_THINK_TAG.length; + this.insideThink = true; + // Skip a single leading whitespace after + if (pos < buffer.length && (buffer[pos] === "\n" || buffer[pos] === "\r")) { + pos += 1; + if (pos < buffer.length && buffer[pos] === "\n") { + pos += 1; + } + } + } + } + + return { visible, thinking }; + } + + /** + * Call at end of stream to flush any remaining carry. + * If we were inside an unclosed ``, that content is treated as + * thinking. Otherwise the remaining carry is visible text. + */ + finish(): { visible: string; thinking: string } { + const remaining = this.carry; + this.carry = ""; + if (this.insideThink) { + // Unclosed think tag at end of stream — treat as thinking + this.insideThink = false; + return { visible: "", thinking: remaining }; + } + return { visible: remaining, thinking: "" }; + } +} diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts index fa4c1b2..b9e25dc 100644 --- a/src/usage/tracker.ts +++ b/src/usage/tracker.ts @@ -79,7 +79,7 @@ export interface UsageSummary { codebase: UsageDaily; hasData: boolean; /** When true, cost data comes from the OpenCode CLI SQLite database - (actual billed amounts). When false, costs are estimated locally. */ + (actual billed amounts). When false, costs are estimated locally. */ sqliteAvailable: boolean; } From e2e10b89aa62823dd70798a97a408c099d6014b8 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 14:23:40 +0800 Subject: [PATCH 11/22] refactor(models,core): move model metadata + routing into domain folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move healthy single-domain modules into their final homes: - src/models/ — metadata.ts, modelLimits.ts, modelCapabilities.ts, modelNames.ts - src/core/ — routing.ts (transport types already live in core/transport.ts) Update every importer (extension.ts, request/*, thinking/*, transports/*, usage/*, tests, scripts/validate-models.ts, verify-estimate-token-count.ts). Behavior-preserving; pure path churn. --- scripts/validate-models.ts | 2 +- scripts/verify-estimate-token-count.ts | 2 +- src/{ => core}/routing.ts | 2 +- src/extension.ts | 12 ++++++------ src/{ => models}/metadata.ts | 8 ++++---- src/{ => models}/modelCapabilities.ts | 0 src/{ => models}/modelLimits.ts | 2 +- src/{ => models}/modelNames.ts | 0 src/request/anthropic.ts | 4 ++-- src/request/google.ts | 2 +- src/request/openai.ts | 4 ++-- src/test/goUsageTracker.test.ts | 2 +- src/test/metadata.test.ts | 2 +- src/test/modelLimits.test.ts | 2 +- src/test/modelNames.test.ts | 2 +- src/test/visionProxy.test.ts | 2 +- src/thinking/base.ts | 2 +- src/thinking/deepseek.ts | 2 +- src/thinking/fallback.ts | 2 +- src/thinking/glm.ts | 2 +- src/thinking/kimi.ts | 2 +- src/thinking/mimo.ts | 2 +- src/thinking/minimax.ts | 2 +- src/thinking/openai.ts | 2 +- src/thinking/provider.ts | 2 +- src/thinking/qwen.ts | 2 +- src/thinking/resolve.ts | 2 +- src/thinking/schema.ts | 2 +- src/transports/google.ts | 2 +- src/transports/responses.ts | 2 +- src/usage/pricing.ts | 2 +- src/usage/tracker.ts | 2 +- 32 files changed, 40 insertions(+), 40 deletions(-) rename src/{ => core}/routing.ts (99%) rename src/{ => models}/metadata.ts (99%) rename src/{ => models}/modelCapabilities.ts (100%) rename src/{ => models}/modelLimits.ts (97%) rename src/{ => models}/modelNames.ts (100%) diff --git a/scripts/validate-models.ts b/scripts/validate-models.ts index 35ad2bd..91445e3 100644 --- a/scripts/validate-models.ts +++ b/scripts/validate-models.ts @@ -17,7 +17,7 @@ import { parseArgs } from "node:util"; import { thinkingProviderFor, type ThinkingSettings } from "../src/thinking.js"; -import { resolveModelRouting } from "../src/routing.js"; +import { resolveModelRouting } from "../src/core/routing.js"; import { buildOpenCodeGatewayAuthHeaders } from "../src/openCodeAuth.js"; // --------------------------------------------------------------------------- diff --git a/scripts/verify-estimate-token-count.ts b/scripts/verify-estimate-token-count.ts index c24b3ea..e9b608a 100644 --- a/scripts/verify-estimate-token-count.ts +++ b/scripts/verify-estimate-token-count.ts @@ -323,7 +323,7 @@ const testCases: TestCase[] = [ // The "new" budget is computed with the production calculateModelLimits so the // script verifies the real shipped behavior. -import { calculateModelLimits } from "../src/modelLimits.js"; +import { calculateModelLimits } from "../src/models/modelLimits.js"; function computeMaxTokens(promptTokens: number | undefined, contextWindow: number, maxOutputTokens: number): number { const limits = calculateModelLimits({ contextWindow, maxOutputTokens }, { maxInputTokens: contextWindow, promptTokens }); diff --git a/src/routing.ts b/src/core/routing.ts similarity index 99% rename from src/routing.ts rename to src/core/routing.ts index 4890b70..69ac85f 100644 --- a/src/routing.ts +++ b/src/core/routing.ts @@ -1,4 +1,4 @@ -import { GO_VENDOR, ZEN_VENDOR, resolveBaseVendor, type ProviderRoutingDefinition } from "./providerTypes"; +import { GO_VENDOR, ZEN_VENDOR, resolveBaseVendor, type ProviderRoutingDefinition } from "../providerTypes"; function isMessagesQwenModel(modelId: string): boolean { return /^qwen3\.(?:5|6)-plus(?:-free)?$/i.test(modelId) || /^qwen3\.7-max$/i.test(modelId); diff --git a/src/extension.ts b/src/extension.ts index c479d75..afab5b9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -17,8 +17,8 @@ import { type ModelMetadataFields, type ModelsDevResponse, type ResolvedModelMetadata, -} from "./metadata"; -import { resolveModelRouting } from "./routing"; +} from "./models/metadata"; +import { resolveModelRouting } from "./core/routing"; import { extractThinkingOverride, resolveThinkingConfig, thinkingFamily, thinkingProviderFor, type ThinkingSettings } from "./thinking"; import { shouldEchoThinkingHistory, thinkingTextFromValue } from "./reasoningHistory"; import { buildOpenCodeGatewayAuthHeaders } from "./openCodeAuth"; @@ -44,9 +44,9 @@ import { registerInlineCompletions } from "./autocomplete"; import { completionUsageToSeries, type CompletionUsageDay } from "./autocomplete/usage"; import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "./imageNormalizer"; import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache"; -import { providerModelDisplayName } from "./modelNames"; -import { buildStableModelCapabilities } from "./modelCapabilities"; -import { calculateModelLimits, type ModelLimits } from "./modelLimits"; +import { providerModelDisplayName } from "./models/modelNames"; +import { buildStableModelCapabilities } from "./models/modelCapabilities"; +import { calculateModelLimits, type ModelLimits } from "./models/modelLimits"; import { buildAnthropicMessagesRequestBody, buildChatCompletionsRequestBody, @@ -143,7 +143,7 @@ import { sleep, toFiniteNumber, } from "./utils"; -import { isFreeModel } from "./metadata"; +import { isFreeModel } from "./models/metadata"; import { formatCacheHitRatio, formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage/usage"; import { diff --git a/src/metadata.ts b/src/models/metadata.ts similarity index 99% rename from src/metadata.ts rename to src/models/metadata.ts index f8cafd0..75c57fa 100644 --- a/src/metadata.ts +++ b/src/models/metadata.ts @@ -1,4 +1,4 @@ -import { GO_VENDOR, ZEN_VENDOR, type ProviderVendor, type AllProviderVendor } from "./providerTypes"; +import { GO_VENDOR, ZEN_VENDOR, type ProviderVendor, type AllProviderVendor } from "../providerTypes"; export interface BaseModelLimits { contextWindow: number; @@ -145,10 +145,10 @@ import { FREE_ZEN_MODEL_IDS, MODEL_METADATA_CACHE_TTL_MS, MODEL_METADATA_REVISION, -} from "./config"; -import { positiveNumber } from "./utils"; +} from "../config"; +import { positiveNumber } from "../utils"; -export { MODELS_DEV_API_URL, MODEL_METADATA_REVISION, MODEL_METADATA_CACHE_KEY, MODEL_METADATA_CACHE_TTL_MS } from "./config"; +export { MODELS_DEV_API_URL, MODEL_METADATA_REVISION, MODEL_METADATA_CACHE_KEY, MODEL_METADATA_CACHE_TTL_MS } from "../config"; const DEFAULT_MODEL_LIMITS: BaseModelLimits = { contextWindow: DEFAULT_MODEL_CONTEXT_WINDOW, diff --git a/src/modelCapabilities.ts b/src/models/modelCapabilities.ts similarity index 100% rename from src/modelCapabilities.ts rename to src/models/modelCapabilities.ts diff --git a/src/modelLimits.ts b/src/models/modelLimits.ts similarity index 97% rename from src/modelLimits.ts rename to src/models/modelLimits.ts index b8fa9d9..b128234 100644 --- a/src/modelLimits.ts +++ b/src/models/modelLimits.ts @@ -1,5 +1,5 @@ import type { BaseModelLimits } from "./metadata"; -import { UI_OUTPUT_TOKEN_RESERVE, MIN_TOKEN_ESTIMATE_SAFETY_MARGIN, TOKEN_ESTIMATE_SAFETY_RATIO } from "./config"; +import { UI_OUTPUT_TOKEN_RESERVE, MIN_TOKEN_ESTIMATE_SAFETY_MARGIN, TOKEN_ESTIMATE_SAFETY_RATIO } from "../config"; export interface ModelLimits extends BaseModelLimits { advertisedContextWindow: number; diff --git a/src/modelNames.ts b/src/models/modelNames.ts similarity index 100% rename from src/modelNames.ts rename to src/models/modelNames.ts diff --git a/src/request/anthropic.ts b/src/request/anthropic.ts index a724bb7..730473b 100644 --- a/src/request/anthropic.ts +++ b/src/request/anthropic.ts @@ -12,8 +12,8 @@ import { joinedTextContent } from "../responsesRequest"; import { thinkingProviderFor } from "../thinking"; import { sanitizeToolSchema } from "./schema"; import { messagesHaveImages } from "./shared"; -import type { ResolvedModelMetadata } from "../metadata"; -import type { ModelLimits } from "../modelLimits"; +import type { ResolvedModelMetadata } from "../models/metadata"; +import type { ModelLimits } from "../models/modelLimits"; import type { ApiMessage, ApiSettings, diff --git a/src/request/google.ts b/src/request/google.ts index 8448ec0..c498afe 100644 --- a/src/request/google.ts +++ b/src/request/google.ts @@ -11,7 +11,7 @@ import * as vscode from "vscode"; import { joinedTextContent } from "../responsesRequest"; import { parseToolInput } from "../toolCallAccumulator"; import { sanitizeToolSchema } from "./schema"; -import type { ModelLimits } from "../modelLimits"; +import type { ModelLimits } from "../models/modelLimits"; import type { ApiMessage, ApiSettings } from "./types"; export function buildGoogleGenerateContentBody( diff --git a/src/request/openai.ts b/src/request/openai.ts index e36f6b9..085daab 100644 --- a/src/request/openai.ts +++ b/src/request/openai.ts @@ -12,8 +12,8 @@ import { buildResponsesRequestEnvelope, responsesInputItemsFromMessage } from ". import { thinkingProviderFor } from "../thinking"; import { sanitizeToolSchema } from "./schema"; import { messagesHaveImages } from "./shared"; -import type { ResolvedModelMetadata } from "../metadata"; -import type { ModelLimits } from "../modelLimits"; +import type { ResolvedModelMetadata } from "../models/metadata"; +import type { ModelLimits } from "../models/modelLimits"; import type { ApiMessage, ApiSettings, OpenAiToolDefinition } from "./types"; export function buildChatCompletionsRequestBody( diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 35988cf..8e36d81 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -4,7 +4,7 @@ import Module from "node:module"; import path from "node:path"; import fs from "node:fs"; import os from "node:os"; -import type { ModelCost } from "../metadata.js"; +import type { ModelCost } from "../models/metadata.js"; import type { TransportRequestSummary } from "../streaming.js"; import { GO_USAGE_LOG_KEY, diff --git a/src/test/metadata.test.ts b/src/test/metadata.test.ts index 15f9a79..ce6beaa 100644 --- a/src/test/metadata.test.ts +++ b/src/test/metadata.test.ts @@ -7,7 +7,7 @@ import { resolveModelMetadata, VISION_CAPABLE_MODELS, type CachedModelMetadataSnapshot, -} from "../metadata.js"; +} from "../models/metadata.js"; import { GO_VENDOR, ZEN_VENDOR } from "../providerTypes.js"; /** diff --git a/src/test/modelLimits.test.ts b/src/test/modelLimits.test.ts index 65bd2cb..8a363ef 100644 --- a/src/test/modelLimits.test.ts +++ b/src/test/modelLimits.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { calculateModelLimits } from "../modelLimits.js"; +import { calculateModelLimits } from "../models/modelLimits.js"; const metadata = { contextWindow: 100_000, diff --git a/src/test/modelNames.test.ts b/src/test/modelNames.test.ts index 4deb8e4..e2b3dd2 100644 --- a/src/test/modelNames.test.ts +++ b/src/test/modelNames.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { formatModelName, providerModelDisplayName } from "../modelNames.js"; +import { formatModelName, providerModelDisplayName } from "../models/modelNames.js"; describe("provider model display names", () => { it("formats numeric model versions like the existing picker", () => { diff --git a/src/test/visionProxy.test.ts b/src/test/visionProxy.test.ts index ca18ea9..9f78bee 100644 --- a/src/test/visionProxy.test.ts +++ b/src/test/visionProxy.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { buildStableModelCapabilities } from "../modelCapabilities"; +import { buildStableModelCapabilities } from "../models/modelCapabilities"; import { clearImageDescriptionCache, IMAGE_DESCRIPTION_CACHE_LIMIT, diff --git a/src/thinking/base.ts b/src/thinking/base.ts index 6faf2a9..d6485e9 100644 --- a/src/thinking/base.ts +++ b/src/thinking/base.ts @@ -8,7 +8,7 @@ * * CONTRACT: pure only — no `vscode` import, no side effects. */ -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; // Type-only import from the sibling registry module — erased at runtime, so // there is no circular dependency between the base class and the concrete // providers that extend it. diff --git a/src/thinking/deepseek.ts b/src/thinking/deepseek.ts index f62c782..1e3f841 100644 --- a/src/thinking/deepseek.ts +++ b/src/thinking/deepseek.ts @@ -7,7 +7,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; const DEEPSEEK_EFFORTS = ["off", "low", "medium", "high", "max"] as const; diff --git a/src/thinking/fallback.ts b/src/thinking/fallback.ts index 5d55f9b..13b8906 100644 --- a/src/thinking/fallback.ts +++ b/src/thinking/fallback.ts @@ -7,7 +7,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, genericReasoningSchema, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; export class FallbackThinking extends BaseThinkingProvider { diff --git a/src/thinking/glm.ts b/src/thinking/glm.ts index adcca1d..40ee2c5 100644 --- a/src/thinking/glm.ts +++ b/src/thinking/glm.ts @@ -7,7 +7,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; const GLM_EFFORTS = ["off", "high", "max"] as const; diff --git a/src/thinking/kimi.ts b/src/thinking/kimi.ts index 91b2a9e..eb6b252 100644 --- a/src/thinking/kimi.ts +++ b/src/thinking/kimi.ts @@ -7,7 +7,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; // "off" first so the picker shows Off → On (matches the workspace default flow). diff --git a/src/thinking/mimo.ts b/src/thinking/mimo.ts index a5636ab..4ed3fa4 100644 --- a/src/thinking/mimo.ts +++ b/src/thinking/mimo.ts @@ -11,7 +11,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; const MIMO_EFFORTS = ["off", "low", "medium", "high"] as const; diff --git a/src/thinking/minimax.ts b/src/thinking/minimax.ts index 59f3217..94704cb 100644 --- a/src/thinking/minimax.ts +++ b/src/thinking/minimax.ts @@ -7,7 +7,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; const MINIMAX_MODES = ["off", "on"] as const; diff --git a/src/thinking/openai.ts b/src/thinking/openai.ts index b573577..694ab41 100644 --- a/src/thinking/openai.ts +++ b/src/thinking/openai.ts @@ -7,7 +7,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; const OPENAI_EFFORTS = ["off", "low", "medium", "high", "xhigh"] as const; diff --git a/src/thinking/provider.ts b/src/thinking/provider.ts index 6a0ff92..a22ac1a 100644 --- a/src/thinking/provider.ts +++ b/src/thinking/provider.ts @@ -7,7 +7,7 @@ * * CONTRACT: pure only — no `vscode` import, no side effects. */ -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; import { DeepSeekThinking } from "./deepseek"; import { GlmThinking } from "./glm"; diff --git a/src/thinking/qwen.ts b/src/thinking/qwen.ts index 0616215..538653e 100644 --- a/src/thinking/qwen.ts +++ b/src/thinking/qwen.ts @@ -8,7 +8,7 @@ */ import { BaseThinkingProvider } from "./base"; import { schemaFromReasoningOptions, effortProperty, type ThinkingSchema } from "./schema"; -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingFamily, BuildThinkingPayloadOptions } from "./types"; const QWEN_MODES = ["auto", "on", "off"] as const; diff --git a/src/thinking/resolve.ts b/src/thinking/resolve.ts index 0eaab77..ad725e3 100644 --- a/src/thinking/resolve.ts +++ b/src/thinking/resolve.ts @@ -12,7 +12,7 @@ * CONTRACT: pure only — no `vscode` import, no side effects. The extension * provides the raw sources; this module resolves them. */ -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; import type { ThinkingSettings, ThinkingSource, ResolvedThinking, ThinkingOverride } from "./types"; import { thinkingProviderFor } from "./provider"; diff --git a/src/thinking/schema.ts b/src/thinking/schema.ts index cc697f8..a87780d 100644 --- a/src/thinking/schema.ts +++ b/src/thinking/schema.ts @@ -3,7 +3,7 @@ * * CONTRACT: pure functions only — no `vscode` import, no side effects. */ -import type { ResolvedModelMetadata } from "../metadata"; +import type { ResolvedModelMetadata } from "../models/metadata"; /** A plain JSON-schema-like object; the caller adds the VS Code annotation. */ export interface ThinkingSchema { diff --git a/src/transports/google.ts b/src/transports/google.ts index 722c201..a996783 100644 --- a/src/transports/google.ts +++ b/src/transports/google.ts @@ -1,5 +1,5 @@ import type { StreamRequestOptions } from "../core/transport"; -import { normalizeGoogleFullResponse, normalizeGoogleStreamEvent } from "../routing"; +import { normalizeGoogleFullResponse, normalizeGoogleStreamEvent } from "../core/routing"; import { createThinkTagFilter } from "./thinkTags"; import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; import { OpenAiResponseExtractor } from "./extractors"; diff --git a/src/transports/responses.ts b/src/transports/responses.ts index ed3f6e2..1d9204b 100644 --- a/src/transports/responses.ts +++ b/src/transports/responses.ts @@ -1,5 +1,5 @@ import type { StreamRequestOptions } from "../core/transport"; -import { normalizeResponsesFullResponse, normalizeResponsesStreamEvent } from "../routing"; +import { normalizeResponsesFullResponse, normalizeResponsesStreamEvent } from "../core/routing"; import { createThinkTagFilter } from "./thinkTags"; import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; import { OpenAiResponseExtractor } from "./extractors"; diff --git a/src/usage/pricing.ts b/src/usage/pricing.ts index bc46096..75546e7 100644 --- a/src/usage/pricing.ts +++ b/src/usage/pricing.ts @@ -1,4 +1,4 @@ -import type { ModelCost } from "../metadata"; +import type { ModelCost } from "../models/metadata"; /** Callback to resolve live model cost from the models.dev metadata cache. */ export type CostResolver = (modelId: string) => ModelCost | undefined; diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts index b9e25dc..4f81594 100644 --- a/src/usage/tracker.ts +++ b/src/usage/tracker.ts @@ -1,5 +1,5 @@ import * as vscode from "vscode"; -import type { ModelCost } from "../metadata"; +import type { ModelCost } from "../models/metadata"; import type { TransportRequestSummary } from "../streaming"; import { fetchGoUsage, mergeServerUsage, GO_USAGE_SYNC_TTL_MS, type GoUsageApiResponse } from "./goUsageSync"; import { From 2622c1e3c1005e2bf8135be5ed955ab03ab70e23 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 14:48:12 +0800 Subject: [PATCH 12/22] refactor(extension): extract usage dashboard + model metadata fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract ~1150 lines out of extension.ts: - src/usage/dashboard.ts — usage state, status bar, webview panel (incl. the 583-line HTML template), tooltip SVG builder, tracker/profile plumbing - src/models/metadataFetcher.ts — models.dev metadata cache state + the clear/get/refresh orchestration (owns its own in-memory cache) extension.ts keeps live references via exported getters/setters. Verified: compile + 291 tests + lint green. --- src/extension.ts | 1461 ++------------------------------- src/models/metadataFetcher.ts | 102 +++ src/usage/dashboard.ts | 1317 +++++++++++++++++++++++++++++ src/usage/tracker.ts | 2 +- 4 files changed, 1480 insertions(+), 1402 deletions(-) create mode 100644 src/models/metadataFetcher.ts create mode 100644 src/usage/dashboard.ts diff --git a/src/extension.ts b/src/extension.ts index afab5b9..6a50051 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,21 +1,15 @@ import * as vscode from "vscode"; import { OpenCodeRequestError } from "./errors"; import { - MODEL_METADATA_CACHE_KEY, MODEL_METADATA_REVISION, - MODELS_DEV_API_URL, - bundledModelMetadataSnapshot, getContextSizeOptionsForModel, hasExplicitModelLimits, - isFreshModelMetadata, normalizeLiveModelMetadata, - normalizeModelsDevSnapshot, resolveModelMetadata, toEffectiveModelId, VISION_CAPABLE_MODELS, type CachedModelMetadataSnapshot, type ModelMetadataFields, - type ModelsDevResponse, type ResolvedModelMetadata, } from "./models/metadata"; import { resolveModelRouting } from "./core/routing"; @@ -41,7 +35,6 @@ import { import { providerEnabledSetting } from "./providerEnablement"; import { isInternalDataPart, isReasoningMarkerPart, readReasoningMarker } from "./chatParts"; import { registerInlineCompletions } from "./autocomplete"; -import { completionUsageToSeries, type CompletionUsageDay } from "./autocomplete/usage"; import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "./imageNormalizer"; import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache"; import { providerModelDisplayName } from "./models/modelNames"; @@ -62,7 +55,6 @@ import { AGENT_HOST_BYOK_ENABLED_SETTING, AGENT_HOST_BYOK_MINOR_VERSION, CAPACITY_LIMITED_MODEL_NOTES, - COMPLETION_USAGE_KEY, CONFIG_SECTION, DEFAULT_REQUEST_TIMEOUT_SECONDS, DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, @@ -81,7 +73,6 @@ import { MODEL_LIST_FETCH_MAX_RETRIES, MODEL_LIST_FETCH_RETRY_BASE_MS, MODEL_LIST_FETCH_TIMEOUT_MS, - MODEL_METADATA_FETCH_TIMEOUT_MS, OPEN_CODE_CLIENT, RECENT_TRANSPORT_SUMMARY_LIMIT, RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX, @@ -116,45 +107,14 @@ import { TOOL_RESULT_TOKEN_OVERHEAD, VISION_PROXY_MODEL_ID_KEY, VISION_PROXY_PROMPT_KEY, - DEFAULT_USAGE_CODEBASE_ROW, - DEFAULT_USAGE_CODEBASE_WINDOW_DAYS, - DEFAULT_USAGE_DAY_BOUNDARY, - DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS, DEFAULT_USAGE_CHART_DAYS, - DEFAULT_USAGE_ROLLING_SESSION_METER, - DEFAULT_USAGE_TODAY_YESTERDAY_SOURCE, - SETTING_USAGE_CODEBASE_ROW, - SETTING_USAGE_CODEBASE_WINDOW_DAYS, - SETTING_USAGE_DAY_BOUNDARY, - SETTING_USAGE_REFRESH_INTERVAL_SECONDS, SETTING_USAGE_CHART_DAYS, - SETTING_USAGE_ROLLING_SESSION_METER, - SETTING_USAGE_TODAY_YESTERDAY_SOURCE, - type UsageTodayYesterdaySource, } from "./config"; -import { - escapeHtml, - formatCount, - formatRelativeTime, - formatTokenCount, - formatUsd, - getErrorMessage, - isRecord, - sleep, - toFiniteNumber, -} from "./utils"; +import { formatCount, formatTokenCount, formatUsd, getErrorMessage, isRecord, sleep, toFiniteNumber } from "./utils"; import { isFreeModel } from "./models/metadata"; +import { formatCacheHitRatio } from "./usage/usage"; -import { formatCacheHitRatio, formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage/usage"; -import { - GoUsageTracker, - GO_LIMITS, - formatGoUsageStatusBarText, - buildUsageQuickPickItems, - estimateCost, - type GoUsageTrackerOptions, - type UsageBaselineTargets, -} from "./goUsageTracker"; +import { GoUsageTracker, buildUsageQuickPickItems, estimateCost } from "./goUsageTracker"; import { resolveResponseApiKey } from "./apiKeyResolution"; import { LEGACY_FINGERPRINT, @@ -164,214 +124,43 @@ import { writeActiveProfile, writeProfiles, readActiveProfiles, - readMigratedTo, - writeMigratedTo, findProfile, renameProfile, - nonLegacyCount, - type UsageProfile, } from "./usage/usageProfile"; - -/** - * VS Code core settings the extension manages (auto-configures and reverts) - * so OpenCode models work in the Agents window (issue #122): - * - * - `chat.agentHost.byokModels.enabled`: wires the agent-host BYOK bridge - * (VS Code 1.129+); off by default, so extension-provided BYOK models never - * reach agent-host sessions until it is flipped on. - * - `extensions.supportAgentsWindow.`: the ONLY way a code extension is - * allowed to run in the Agents window (sessions window) process. Without - * it the extension is disabled there, its `languageModelChatProviders` - * vendors are not registered, and neither the model picker nor the - * "+ Add Models" list knows OpenCode Go/Zen. - */ - -let usageStatusBarItem: vscode.StatusBarItem | undefined; -let goUsageStatusBarItem: vscode.StatusBarItem | undefined; -/** Singleton tracker — the first/legacy account. Used for backward compat until first migration. */ -let goUsageTracker: GoUsageTracker | undefined; -/** Per-profile trackers indexed by key fingerprint. */ -const goUsageTrackers = new Map(); -/** API key per profile fingerprint — lets refreshes sync the active profile's own key. */ -const profileApiKeys = new Map(); -let usageWebviewPanel: vscode.WebviewPanel | undefined; - -let profilesCache: UsageProfile[] = []; -let activeProfileFingerprint: string = LEGACY_FINGERPRINT; - -/** - * Resolvers for the per-view usage knobs, read live from configuration so - * changing a setting repaints the status bar / tooltip / card immediately. - */ -function usageTrackerOptions(): GoUsageTrackerOptions { - const config = () => vscode.workspace.getConfiguration(CONFIG_SECTION); - return { - resolveWorkspaceFolders: () => vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [], - resolveTodayYesterdaySource: () => - config().get(SETTING_USAGE_TODAY_YESTERDAY_SOURCE, DEFAULT_USAGE_TODAY_YESTERDAY_SOURCE), - resolveCodebaseWindowDays: () => config().get(SETTING_USAGE_CODEBASE_WINDOW_DAYS, DEFAULT_USAGE_CODEBASE_WINDOW_DAYS), - resolveDayBoundary: () => config().get<"utc" | "local">(SETTING_USAGE_DAY_BOUNDARY, DEFAULT_USAGE_DAY_BOUNDARY), - }; -} - -/** Whether the detailed usage views show the server 5h rolling meter. */ -function usageRollingMeterVisible(): boolean { - return vscode.workspace - .getConfiguration(CONFIG_SECTION) - .get(SETTING_USAGE_ROLLING_SESSION_METER, DEFAULT_USAGE_ROLLING_SESSION_METER); -} - -/** Whether the detailed usage views show the all-time codebase row. */ -function usageCodebaseRowVisible(): boolean { - return vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_USAGE_CODEBASE_ROW, DEFAULT_USAGE_CODEBASE_ROW); -} - -/** Every usage-view setting — a change to any of these repaints immediately. */ -const USAGE_DISPLAY_SETTING_KEYS = [ - SETTING_USAGE_TODAY_YESTERDAY_SOURCE, - SETTING_USAGE_CODEBASE_ROW, - SETTING_USAGE_CODEBASE_WINDOW_DAYS, - SETTING_USAGE_DAY_BOUNDARY, - SETTING_USAGE_ROLLING_SESSION_METER, - SETTING_USAGE_REFRESH_INTERVAL_SECONDS, -]; - -/** - * Realtime usage updates: re-render the status bar (and webview) on a - * configurable cadence so terminal-side OpenCode CLI usage, server meters and - * day rollovers show up without waiting for the next chat request. The - * interval re-reads the setting on every tick, so changes apply live. - */ -function startUsageRefreshLoop(context: vscode.ExtensionContext): void { - let timer: ReturnType | undefined; - const schedule = (): void => { - timer = setTimeout(() => { - refreshGoUsageStatusBar(); - schedule(); - }, usageRefreshIntervalSeconds() * 1000); - }; - schedule(); - context.subscriptions.push({ - dispose: () => { - if (timer) clearTimeout(timer); - }, - }); -} - -function usageRefreshIntervalSeconds(): number { - return Math.max( - 5, - vscode.workspace - .getConfiguration(CONFIG_SECTION) - .get(SETTING_USAGE_REFRESH_INTERVAL_SECONDS, DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS), - ); -} - -/** Look up (or create) the GoUsageTracker for a given key fingerprint. */ -function getOrCreateTracker(fingerprint: string): GoUsageTracker { - // The singleton tracker does not have a storage suffix - if (fingerprint === LEGACY_FINGERPRINT && goUsageTracker) return goUsageTracker; - let tracker = goUsageTrackers.get(fingerprint); - if (tracker) return tracker; - tracker = new GoUsageTracker( - extensionContext(), - (msg) => { - usageLogChannel().appendLine(`[${new Date().toISOString()}] [${fingerprint}] ${msg}`); - }, - (modelId) => modelMetadataSnapshot?.providers[GO_VENDOR]?.[modelId]?.cost, - fingerprint, - usageTrackerOptions(), - ); - goUsageTrackers.set(fingerprint, tracker); - return tracker; -} - -/** Return the tracker for the currently active profile. */ -function activeGoUsageTracker(): GoUsageTracker | undefined { - if (activeProfileFingerprint === LEGACY_FINGERPRINT) return goUsageTracker; - return goUsageTrackers.get(activeProfileFingerprint); -} - -/** Switch the active profile and refresh the UI. */ -async function setActiveProfile(fingerprint: string): Promise { - activeProfileFingerprint = fingerprint; - await writeActiveProfile(extensionContext(), fingerprint); - refreshGoUsageStatusBar(); - updateWebviewContent(); -} - -/** - * Ensure a profile exists in the in-memory cache for the given API key. - * This is called both from provideLanguageModelChatInformation (at startup, - * when VS Code resolves all providers) and from onTransportSummary (when - * a request completes). The first call creates the profile; subsequent - * calls are no-ops. Persistence is fire-and-forget. - */ -function ensureProfileSync(apiKey: string): void { - const fp = keyFingerprint(apiKey); - const tracker = getOrCreateTracker(fp); - - if (!findProfile(profilesCache, fp)) { - const nextNumber = nonLegacyCount(profilesCache) + 1; - profilesCache.push({ - fingerprint: fp, - label: `Profile ${String(nextNumber)}`, - lastSeenAt: Date.now(), - }); - void writeProfiles(extensionContext(), profilesCache); - } - - // One-time migration from singleton - if (!readMigratedTo(extensionContext())) { - if (goUsageTracker && fp !== LEGACY_FINGERPRINT) { - tracker.migrateFromSingleton(); - } - void writeMigratedTo(extensionContext(), fp); - profilesCache = readProfiles(extensionContext()); - } - - // Update active profile to this one - activeProfileFingerprint = fp; - void writeActiveProfile(extensionContext(), fp); -} - -/** - * Same as ensureProfileSync, but also refreshes the UI. - * Called from onTransportSummary during request recording. - */ -function ensureProfileForApiKey(apiKey: string): GoUsageTracker { - ensureProfileSync(apiKey); - // Remember which API key owns each profile, so status-bar refreshes can - // sync the ACTIVE profile's meters with its own key instead of the - // extension secret (which may belong to another account). - profileApiKeys.set(keyFingerprint(apiKey), apiKey); - return getOrCreateTracker(keyFingerprint(apiKey)); -} - -let _extensionContext: vscode.ExtensionContext | undefined; -let _usageLogChannel: vscode.OutputChannel | undefined; - -/** - * Returns the extension context, or throws if the extension has not been - * activated yet. Callers must be reached after `activate()` has run. - */ -function extensionContext(): vscode.ExtensionContext { - if (!_extensionContext) { - throw new Error("extension context not initialized"); - } - return _extensionContext; -} - -/** - * Returns the usage log output channel, or throws if the extension has not - * been activated yet. Callers must be reached after `activate()` has run. - */ -function usageLogChannel(): vscode.OutputChannel { - if (!_usageLogChannel) { - throw new Error("usage log channel not initialized"); - } - return _usageLogChannel; -} +import { + USAGE_DISPLAY_SETTING_KEYS, + _extensionContext, + activeGoUsageTracker, + activeProfileFingerprint, + ensureGoUsageStatusBar, + ensureProfileForApiKey, + ensureProfileSync, + ensureUsageStatusBar, + extensionContext, + getOrCreateTracker, + goUsageTrackers, + profileApiKeys, + profilesCache, + refreshGoUsageStatusBar, + resetUsageStatusBar, + setActiveProfile, + setActiveProfileFingerprint, + setExtensionContext, + setGoUsageTracker, + setProfilesCache, + setUsageChartWindowDays, + setUsageLogChannel, + showUsageTargetEditor, + showUsageWebview, + startUsageRefreshLoop, + syncTrackerUsage, + updateUsageStatusBar, + updateWebviewContent, + usageCodebaseRowVisible, + usageRollingMeterVisible, + usageTrackerOptions, +} from "./usage/dashboard"; +import { clearOpenCodeModelMetadataCache, getModelMetadataSnapshot, getOpenCodeModelMetadata } from "./models/metadataFetcher"; interface ProviderDefinition { vendor: AllProviderVendor; @@ -680,9 +469,6 @@ type ConfiguredLanguageModelResponseOptions = vscode.ProvideLanguageModelChatRes * for understanding agent-loop context) without incurring the payload cost. */ -let modelMetadataSnapshot: CachedModelMetadataSnapshot | undefined; -let modelMetadataRefreshPromise: Promise | undefined; - interface RecentTransportSummary extends TransportRequestSummary { recordedAt: string; endpointKind: string; @@ -693,21 +479,23 @@ interface RecentTransportSummary extends TransportRequestSummary { export function activate(context: vscode.ExtensionContext) { const goUsageLogChannel = vscode.window.createOutputChannel("OpenCode Go Usage"); context.subscriptions.push(goUsageLogChannel); - goUsageTracker = new GoUsageTracker( - context, - (msg) => { - goUsageLogChannel.appendLine(`[${new Date().toISOString()}] ${msg}`); - }, - (modelId) => { - return modelMetadataSnapshot?.providers[GO_VENDOR]?.[modelId]?.cost; - }, - "", - usageTrackerOptions(), + setGoUsageTracker( + new GoUsageTracker( + context, + (msg) => { + goUsageLogChannel.appendLine(`[${new Date().toISOString()}] ${msg}`); + }, + (modelId) => { + return getModelMetadataSnapshot()?.providers[GO_VENDOR]?.[modelId]?.cost; + }, + "", + usageTrackerOptions(), + ), ); - _extensionContext = context; - _usageLogChannel = goUsageLogChannel; - profilesCache = readProfiles(context); - activeProfileFingerprint = readActiveProfile(context); + setExtensionContext(context); + setUsageLogChannel(goUsageLogChannel); + setProfilesCache(readProfiles(context)); + setActiveProfileFingerprint(readActiveProfile(context)); // Eagerly load the tracker for the active profile so the status bar // has data to display immediately, even before the first request. @@ -866,7 +654,7 @@ export function activate(context: vscode.ExtensionContext) { }); if (!newLabel || !newLabel.trim()) return; await renameProfile(extensionContext(), activeProfileFingerprint, newLabel); - profilesCache = readProfiles(extensionContext()); + setProfilesCache(readProfiles(extensionContext())); refreshGoUsageStatusBar(); updateWebviewContent(); vscode.window.showInformationMessage(`Profile renamed to "${newLabel}".`); @@ -904,10 +692,10 @@ export function activate(context: vscode.ExtensionContext) { const remaining = readProfiles(ctx).filter((p) => p.fingerprint !== fp); await writeProfiles(ctx, remaining); - profilesCache = remaining; + setProfilesCache(remaining); if (activeProfileFingerprint === fp) { - activeProfileFingerprint = LEGACY_FINGERPRINT; + setActiveProfileFingerprint(LEGACY_FINGERPRINT); await writeActiveProfile(ctx, LEGACY_FINGERPRINT); } @@ -972,9 +760,9 @@ export function activate(context: vscode.ExtensionContext) { // immediately (no waiting for the next request or refresh tick). if (USAGE_DISPLAY_SETTING_KEYS.some((key) => event.affectsConfiguration(`${CONFIG_SECTION}.${key}`))) { if (event.affectsConfiguration(`${CONFIG_SECTION}.${SETTING_USAGE_CHART_DAYS}`)) { - usageChartWindowDays = vscode.workspace - .getConfiguration(CONFIG_SECTION) - .get(SETTING_USAGE_CHART_DAYS, DEFAULT_USAGE_CHART_DAYS); + setUsageChartWindowDays( + vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_USAGE_CHART_DAYS, DEFAULT_USAGE_CHART_DAYS), + ); } refreshGoUsageStatusBar(); updateWebviewContent(); @@ -1213,1063 +1001,6 @@ export async function deactivate(): Promise { // no-op: experimental context indicator hooks removed in 0.1.8 } -function ensureUsageStatusBar(context: vscode.ExtensionContext): vscode.StatusBarItem { - if (!usageStatusBarItem) { - usageStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 95); - context.subscriptions.push(usageStatusBarItem); - } - - resetUsageStatusBar(); - return usageStatusBarItem; -} - -function shouldShowUsageStatusBar(): boolean { - return vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_SHOW_USAGE_STATUS_BAR, true); -} - -function resetUsageStatusBar(): void { - if (!usageStatusBarItem) { - return; - } - - if (!shouldShowUsageStatusBar()) { - usageStatusBarItem.hide(); - return; - } - - usageStatusBarItem.text = "OpenCode"; - usageStatusBarItem.tooltip = "OpenCode usage summary"; - usageStatusBarItem.show(); -} - -function updateUsageStatusBar(providerDisplayName: string, modelId: string, summary: TransportRequestSummary): void { - if (!usageStatusBarItem) { - return; - } - - if (!shouldShowUsageStatusBar()) { - usageStatusBarItem.hide(); - return; - } - - const usage: UsageSnapshot = { - promptTokens: summary.promptTokens, - completionTokens: summary.completionTokens, - totalTokens: summary.totalTokens, - cachedTokens: summary.cachedTokens, - finishReason: summary.finishReason, - }; - const text = formatUsageStatusBarText(providerDisplayName, usage); - - usageStatusBarItem.text = text ?? providerDisplayName; - usageStatusBarItem.tooltip = formatUsageStatusBarTooltip(providerDisplayName, modelId, usage); - usageStatusBarItem.show(); -} - -function ensureGoUsageStatusBar(context: vscode.ExtensionContext): void { - if (goUsageStatusBarItem) return; - goUsageStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 94); - goUsageStatusBarItem.command = "opencodego.showUsageQuickPick"; - context.subscriptions.push(goUsageStatusBarItem); - refreshGoUsageStatusBar(); -} - -function refreshGoUsageStatusBar(): void { - if (!goUsageStatusBarItem) return; - const tracker = activeGoUsageTracker(); - if (!tracker) { - goUsageStatusBarItem.text = "OpenCode Go"; - goUsageStatusBarItem.tooltip = new vscode.MarkdownString(""); - goUsageStatusBarItem.show(); - return; - } - const s = tracker.getSummary(); - const activeProfile = findProfile(profilesCache, activeProfileFingerprint); - const baseText = formatGoUsageStatusBarText(s); - goUsageStatusBarItem.text = activeProfile && profilesCache.length > 1 ? `${baseText} [${activeProfile.label}]` : baseText; - goUsageStatusBarItem.tooltip = buildUsageTooltip(s); - goUsageStatusBarItem.show(); - updateWebviewContent(); - - // Refresh the server-accurate meters in the background (TTL-guarded); when - // a new snapshot lands, rebuild the status bar with it. Use the active - // profile's own key when known, falling back to the extension secret. - void (async () => { - const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(secretKeyFor(GO_VENDOR))); - if (!apiKey) return; - const changed = await tracker.syncServerUsage(apiKey); - if (changed) refreshGoUsageStatusBar(); - })(); -} - -/** - * Fetch server-accurate usage for a key and repaint the status bar when a new - * snapshot arrived. Uses the tracker owning that key (creating its profile on - * first use), so multi-account setups keep per-key meters. - */ -async function syncTrackerUsage(tracker: GoUsageTracker, apiKey: string): Promise { - const changed = await tracker.syncServerUsage(apiKey); - if (changed) refreshGoUsageStatusBar(); -} - -function showUsageWebview(context: vscode.ExtensionContext): void { - if (usageWebviewPanel) { - usageWebviewPanel.reveal(vscode.ViewColumn.Beside); - return; - } - - usageWebviewPanel = vscode.window.createWebviewPanel("opencodego.usageWebview", "OpenCode Usage", vscode.ViewColumn.Beside, { - enableScripts: true, - retainContextWhenHidden: true, - }); - - usageWebviewPanel.onDidDispose( - () => { - usageWebviewPanel = undefined; - usageWebviewRendered = false; - }, - null, - context.subscriptions, - ); - - usageWebviewPanel.webview.onDidReceiveMessage( - (message: { type?: string }) => { - switch (message.type) { - case "refresh": - refreshGoUsageStatusBar(); - break; - case "setTargets": - void vscode.commands.executeCommand("opencodego.setUsageTargets"); - break; - case "renameProfile": - void vscode.commands.executeCommand("opencodego.renameActiveProfile"); - break; - case "window": { - const days = Number((message as { days?: unknown }).days); - if (Number.isFinite(days) && days >= 0 && days <= 370) { - usageChartWindowDays = days; - updateWebviewContent(); - } - break; - } - } - }, - null, - context.subscriptions, - ); - - usageWebviewRendered = false; - updateWebviewContent(); -} - -/** Escape a JSON payload for embedding in an HTML - - - - `; -} - -function buildUsageTooltip(s: ReturnType): vscode.MarkdownString { - const md = new vscode.MarkdownString("", true); - md.supportHtml = true; - md.isTrusted = true; - const activeProfile = findProfile(profilesCache, activeProfileFingerprint); - const profileLabel = activeProfile?.label ?? "OpenCode Go"; - - // The hover shows the summary card only; Set spent targets / Rename are - // available from the Command Palette (opencodego.setUsageTargets, - // opencodego.renameActiveProfile). - md.appendMarkdown(`Go usage summary`); - return md; -} - -/** - * Show input boxes for the user to manually set Go usage targets. - * Returns UsageBaselineTargets if the user completed the flow, or undefined if cancelled. - */ -/** Parse a user-entered currency value. Accepts comma or dot as decimal separator. - * Returns NaN if the string contains non-numeric characters beyond the decimal separator. */ -function parseCurrencyInput(value: string): number { - // Allow only digits, one comma or dot, and optional leading minus - if (!/^-?\d+[.,]?\d*$/.test(value)) return NaN; - return parseFloat(value.replace(",", ".")); -} - -async function showUsageTargetEditor(tracker: GoUsageTracker): Promise { - const summary = tracker.getSummary(); - - // Ask for session spent (pre-filled with current tracked value) - const sessionStr = await vscode.window.showInputBox({ - title: "OpenCode Go — Session Spent", - prompt: `Total spent in the 5-hour rolling window (limit: $${String(GO_LIMITS.session)}).`, - placeHolder: "e.g. 3.50", - value: summary.session.spent.toFixed(2), - validateInput: (value: string) => { - const n = parseCurrencyInput(value); - if (isNaN(n) || n < 0) return "Enter a valid number using digits and . or , as decimal separator (e.g. 3.50)."; - if (n > GO_LIMITS.session) - return `Session limit is $${String(GO_LIMITS.session)}. Enter a value between 0 and ${String(GO_LIMITS.session)}.`; - return undefined; - }, - }); - if (sessionStr === undefined) return undefined; - - // Ask for weekly spent (pre-filled) - const weeklyStr = await vscode.window.showInputBox({ - title: "OpenCode Go — Weekly Spent", - prompt: `Total spent this week Mon–Mon UTC (limit: $${String(GO_LIMITS.weekly)}).`, - placeHolder: "e.g. 12.00", - value: summary.weekly.spent.toFixed(2), - validateInput: (value: string) => { - const n = parseCurrencyInput(value); - if (isNaN(n) || n < 0) return "Enter a valid number using digits and . or , as decimal separator (e.g. 12.00)."; - if (n > GO_LIMITS.weekly) - return `Weekly limit is $${String(GO_LIMITS.weekly)}. Enter a value between 0 and ${String(GO_LIMITS.weekly)}.`; - return undefined; - }, - }); - if (weeklyStr === undefined) return undefined; - - // Ask for monthly spent (pre-filled) - const monthlyStr = await vscode.window.showInputBox({ - title: "OpenCode Go — Monthly Spent", - prompt: `Total spent this month (limit: $${String(GO_LIMITS.monthly)}).`, - placeHolder: "e.g. 25.00", - value: summary.monthly.spent.toFixed(2), - validateInput: (value: string) => { - const n = parseCurrencyInput(value); - if (isNaN(n) || n < 0) return "Enter a valid number using digits and . or , as decimal separator (e.g. 25.00)."; - if (n > GO_LIMITS.monthly) - return `Monthly limit is $${String(GO_LIMITS.monthly)}. Enter a value between 0 and ${String(GO_LIMITS.monthly)}.`; - return undefined; - }, - }); - if (monthlyStr === undefined) return undefined; - - // Ask for monthly reset day (1-31) — pre-filled - const monthlyDayStr = await vscode.window.showInputBox({ - title: "OpenCode Go — Monthly Reset Day", - prompt: "Day of month when monthly usage resets (1-31). Press Enter to keep current.", - placeHolder: "e.g. 10", - value: summary.monthly.resetsAt.getUTCDate().toString(), - validateInput: (value: string) => { - if (!value) return undefined; - const n = parseInt(value, 10); - if (isNaN(n) || n < 1 || n > 31) return "Enter a day between 1 and 31."; - return undefined; - }, - }); - if (monthlyDayStr === undefined) return undefined; - - // Ask for monthly reset hour (0-23 UTC) — pre-filled - const monthlyHourStr = await vscode.window.showInputBox({ - title: "OpenCode Go — Monthly Reset Hour", - prompt: "Hour (UTC, 0-23) when monthly usage resets. Press Enter to keep current.", - placeHolder: "e.g. 0", - value: summary.monthly.resetsAt.getUTCHours().toString(), - validateInput: (value: string) => { - if (!value) return undefined; - const n = parseInt(value, 10); - if (isNaN(n) || n < 0 || n > 23) return "Enter an hour between 0 and 23 (UTC)."; - return undefined; - }, - }); - if (monthlyHourStr === undefined) return undefined; - - const monthlyAnchorDay = monthlyDayStr ? parseInt(monthlyDayStr, 10) : undefined; - const monthlyAnchorHour = monthlyHourStr ? parseInt(monthlyHourStr, 10) : undefined; - - return { - session: parseCurrencyInput(sessionStr), - weekly: parseCurrencyInput(weeklyStr), - monthly: parseCurrencyInput(monthlyStr), - monthlyAnchorDay, - monthlyAnchorHour, - }; -} - -type _UsageSummary = ReturnType; - -function usageTooltipSvgDataUri(s: _UsageSummary, profileLabel?: string): string { - const svg = buildUsageTooltipSvg(s, profileLabel); - return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; -} - -function buildUsageTooltipSvg(s: _UsageSummary, profileLabel?: string): string { - // Stable geometry: fixed card width and fixed columns, so the layout never - // shifts when session data appears or a day has no usage yet. - const width = 440; - const padX = 14; - const right = width - padX; - const fg = "#d4d4d4"; - const muted = "#a6a6a6"; - const track = "#3c3c3c"; - const accent = "#73c991"; - const line = "#333333"; - const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; - - const svgTitle = escapeHtml(profileLabel ? `${profileLabel} - Usage` : "OpenCode Go - Usage"); - const noDataMsg = s.hasData ? null : nonLegacyCount(profilesCache) > 0 ? "No data yet for this profile." : "No usage data yet."; - - const text = (value: string, x: number, y: number, size: number, weight = 400, color = fg, anchor: "start" | "end" = "start"): string => - `${escapeHtml(value)}`; - - const bar = (pct: number, x: number, y: number, barWidth: number): string => { - const clamped = Math.min(Math.max(pct, 0), 100); - const fillWidth = Math.max(0, Math.round((clamped / 100) * barWidth)); - return [ - ``, - fillWidth > 0 ? `` : "", - ].join(""); - }; - - // Meter block with a uniform 14px gutter between blocks: label row with the - // reset time right-aligned at the card's right padding, then the bar and - // the spent/limit line below it. - const period = (label: string, p: _UsageSummary["session"], y: number): string => - [ - text(label, padX, y, 14, 700), - text(`Resets in ${formatRelativeTime(p.resetsAt)}`, right, y, 12, 400, muted, "end"), - bar(p.percent, padX, y + 14, 340), - text(`${p.percent.toFixed(1)}%`, right, y + 21, 14, 700, fg, "end"), - text(`${formatUsd(p.spent)} / ${formatUsd(p.limit)} used`, padX, y + 36, 13, 400, fg), - ].join(""); - - // Device-local rows share one fixed column grid: label, cost, requests, - // tokens. Always rendered (zeros included) so the card height is stable. - const deviceRow = (label: string, cost: number, requests: number, tokenCount: number, y: number): string => - [ - text(label, padX, y, 13, 400, muted), - text(formatUsd(cost), 120, y, 13, 700), - text("Requests:", 190, y, 13, 400, muted), - text(formatCount(requests), 262, y, 13, 700), - text("Tokens:", 305, y, 13, 400, muted), - text(formatTokenCount(tokenCount), 385, y, 13, 700), - ].join(""); - - if (!s.hasData) { - return `${text(svgTitle, padX, 28, 16, 700)} -${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} -`; - } - - // Title starts at the same 14px gutter as the sides. Meter rows, the - // divider and the device rows keep a consistent 14px rhythm. - const meterRows = [ - ...(usageRollingMeterVisible() ? ([["Session (5h rolling)", s.session, 56]] as const) : []), - ["Weekly", s.weekly, 116], - ["Monthly", s.monthly, 176], - ] as const; - const dividerY = 46 + meterRows.length * 60; - const firstRowY = dividerY + 22; - const rowGap = 24; - // All three rows are always rendered (zeros included) so the card is - // stable regardless of whether a session is currently active. - const deviceRows: Array<[string, number, number, number, number]> = []; - if (usageCodebaseRowVisible()) { - deviceRows.push(["Codebase:", s.codebase.cost, s.codebase.requests, s.codebase.tokens, firstRowY]); - } - const codebaseOffset = usageCodebaseRowVisible() ? 1 : 0; - deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + codebaseOffset * rowGap]); - deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + (codebaseOffset + 1) * rowGap]); - - const height = firstRowY + (deviceRows.length - 1) * rowGap + 14; - - return ` -${text(svgTitle, padX, 28, 16, 700)} -${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} - -${deviceRows.map(([label, cost, requests, tokenCount, y]) => deviceRow(label, cost, requests, tokenCount, y)).join("")}`; -} - class OpenCodeProvider implements vscode.LanguageModelChatProvider { private readonly changeEmitter = new vscode.EventEmitter(); readonly onDidChangeLanguageModelChatInformation = this.changeEmitter.event; @@ -3387,78 +2118,6 @@ function getConfiguredApiKey(options?: { configuration?: LanguageModelConfigurat const configuredApiKey = options?.configuration?.apiKey; return typeof configuredApiKey === "string" && configuredApiKey.trim() ? configuredApiKey.trim() : undefined; } - -async function clearOpenCodeModelMetadataCache(context: vscode.ExtensionContext): Promise { - modelMetadataSnapshot = undefined; - modelMetadataRefreshPromise = undefined; - await context.globalState.update(MODEL_METADATA_CACHE_KEY, undefined); -} - -async function getOpenCodeModelMetadata( - context: vscode.ExtensionContext, - output?: vscode.OutputChannel, -): Promise { - const cached = modelMetadataSnapshot ?? context.globalState.get(MODEL_METADATA_CACHE_KEY); - if (cached) { - modelMetadataSnapshot = cached; - if (isFreshModelMetadata(cached)) { - return cached; - } - void refreshOpenCodeModelMetadata(context, output); - return cached; - } - - return refreshOpenCodeModelMetadata(context, output); -} - -async function refreshOpenCodeModelMetadata( - context: vscode.ExtensionContext, - output?: vscode.OutputChannel, -): Promise { - if (modelMetadataRefreshPromise) { - return modelMetadataRefreshPromise; - } - - modelMetadataRefreshPromise = (async () => { - const response = await fetch(MODELS_DEV_API_URL, { - signal: AbortSignal.timeout(MODEL_METADATA_FETCH_TIMEOUT_MS), - }); - - if (!response.ok) { - throw new Error(`models.dev request failed (${String(response.status)}): ${response.statusText}`); - } - - const data = (await response.json()) as ModelsDevResponse; - const snapshot = normalizeModelsDevSnapshot(data); - modelMetadataSnapshot = snapshot; - await context.globalState.update(MODEL_METADATA_CACHE_KEY, snapshot); - output?.appendLine( - `[metadata] refreshed models.dev cache go=${Object.keys(snapshot.providers[GO_VENDOR] ?? {}).length} zen=${Object.keys(snapshot.providers[ZEN_VENDOR] ?? {}).length}`, - ); - return snapshot; - })() - .catch((error: unknown) => { - const cached = modelMetadataSnapshot ?? context.globalState.get(MODEL_METADATA_CACHE_KEY); - if (cached) { - const message = getErrorMessage(error); - output?.appendLine(`[metadata] refresh failed, using cached snapshot: ${message}`); - modelMetadataSnapshot = cached; - return cached; - } - - const message = getErrorMessage(error); - const fallback = bundledModelMetadataSnapshot(); - output?.appendLine(`[metadata] refresh failed, using bundled snapshot: ${message}`); - modelMetadataSnapshot = fallback; - return fallback; - }) - .finally(() => { - modelMetadataRefreshPromise = undefined; - }); - - return modelMetadataRefreshPromise; -} - // (chat/Anthropic/Responses/Google request builders migrated to src/request/builders.ts) // (google request builders migrated to src/request/builders.ts) @@ -4452,7 +3111,7 @@ async function showVisionProxyPicker(context: vscode.ExtensionContext): Promise< // --- Build the set of vision-capable model IDs --- const visionCapableIds = new Set(); - const snapshot = modelMetadataSnapshot; + const snapshot = getModelMetadataSnapshot(); if (snapshot) { for (const vendor of [GO_VENDOR, ZEN_VENDOR] as const) { const provider = snapshot.providers[vendor]; diff --git a/src/models/metadataFetcher.ts b/src/models/metadataFetcher.ts new file mode 100644 index 0000000..e0c2de0 --- /dev/null +++ b/src/models/metadataFetcher.ts @@ -0,0 +1,102 @@ +import * as vscode from "vscode"; +import { MODEL_METADATA_CACHE_KEY, MODEL_METADATA_FETCH_TIMEOUT_MS, MODELS_DEV_API_URL } from "../config"; +import { + bundledModelMetadataSnapshot, + isFreshModelMetadata, + normalizeModelsDevSnapshot, + type CachedModelMetadataSnapshot, + type ModelsDevResponse, +} from "./metadata"; +import { getErrorMessage } from "../utils"; +import { GO_VENDOR, ZEN_VENDOR } from "../providerTypes"; + +/** + * In-memory models.dev metadata cache shared by the provider domain and the + * usage dashboard (cost resolver). Owned here so the fetch orchestration and + * the state never drift apart. + */ +let modelMetadataSnapshot: CachedModelMetadataSnapshot | undefined; +let modelMetadataRefreshPromise: Promise | undefined; + +export function getModelMetadataSnapshot(): CachedModelMetadataSnapshot | undefined { + return modelMetadataSnapshot; +} + +export function setModelMetadataSnapshot(snapshot: CachedModelMetadataSnapshot | undefined): void { + modelMetadataSnapshot = snapshot; +} + +export function setModelMetadataRefreshPromise(promise: Promise | undefined): void { + modelMetadataRefreshPromise = promise; +} + +export async function clearOpenCodeModelMetadataCache(context: vscode.ExtensionContext): Promise { + modelMetadataSnapshot = undefined; + modelMetadataRefreshPromise = undefined; + await context.globalState.update(MODEL_METADATA_CACHE_KEY, undefined); +} + +export async function getOpenCodeModelMetadata( + context: vscode.ExtensionContext, + output?: vscode.OutputChannel, +): Promise { + const cached = modelMetadataSnapshot ?? context.globalState.get(MODEL_METADATA_CACHE_KEY); + if (cached) { + modelMetadataSnapshot = cached; + if (isFreshModelMetadata(cached)) { + return cached; + } + void refreshOpenCodeModelMetadata(context, output); + return cached; + } + + return refreshOpenCodeModelMetadata(context, output); +} + +export async function refreshOpenCodeModelMetadata( + context: vscode.ExtensionContext, + output?: vscode.OutputChannel, +): Promise { + if (modelMetadataRefreshPromise) { + return modelMetadataRefreshPromise; + } + + modelMetadataRefreshPromise = (async () => { + const response = await fetch(MODELS_DEV_API_URL, { + signal: AbortSignal.timeout(MODEL_METADATA_FETCH_TIMEOUT_MS), + }); + + if (!response.ok) { + throw new Error(`models.dev request failed (${String(response.status)}): ${response.statusText}`); + } + + const data = (await response.json()) as ModelsDevResponse; + const snapshot = normalizeModelsDevSnapshot(data); + modelMetadataSnapshot = snapshot; + await context.globalState.update(MODEL_METADATA_CACHE_KEY, snapshot); + output?.appendLine( + `[metadata] refreshed models.dev cache go=${Object.keys(snapshot.providers[GO_VENDOR] ?? {}).length} zen=${Object.keys(snapshot.providers[ZEN_VENDOR] ?? {}).length}`, + ); + return snapshot; + })() + .catch((error: unknown) => { + const cached = modelMetadataSnapshot ?? context.globalState.get(MODEL_METADATA_CACHE_KEY); + if (cached) { + const message = getErrorMessage(error); + output?.appendLine(`[metadata] refresh failed, using cached snapshot: ${message}`); + modelMetadataSnapshot = cached; + return cached; + } + + const message = getErrorMessage(error); + const fallback = bundledModelMetadataSnapshot(); + output?.appendLine(`[metadata] refresh failed, using bundled snapshot: ${message}`); + modelMetadataSnapshot = fallback; + return fallback; + }) + .finally(() => { + modelMetadataRefreshPromise = undefined; + }); + + return modelMetadataRefreshPromise; +} diff --git a/src/usage/dashboard.ts b/src/usage/dashboard.ts new file mode 100644 index 0000000..08c46da --- /dev/null +++ b/src/usage/dashboard.ts @@ -0,0 +1,1317 @@ +import * as vscode from "vscode"; +import { completionUsageToSeries, type CompletionUsageDay } from "../autocomplete/usage"; +import { + COMPLETION_USAGE_KEY, + CONFIG_SECTION, + DEFAULT_USAGE_CHART_DAYS, + DEFAULT_USAGE_CODEBASE_ROW, + DEFAULT_USAGE_CODEBASE_WINDOW_DAYS, + DEFAULT_USAGE_DAY_BOUNDARY, + DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS, + DEFAULT_USAGE_ROLLING_SESSION_METER, + DEFAULT_USAGE_TODAY_YESTERDAY_SOURCE, + GO_LIMITS, + SETTING_SHOW_USAGE_STATUS_BAR, + SETTING_USAGE_CHART_DAYS, + SETTING_USAGE_CODEBASE_ROW, + SETTING_USAGE_CODEBASE_WINDOW_DAYS, + SETTING_USAGE_DAY_BOUNDARY, + SETTING_USAGE_REFRESH_INTERVAL_SECONDS, + SETTING_USAGE_ROLLING_SESSION_METER, + SETTING_USAGE_TODAY_YESTERDAY_SOURCE, + secretKeyFor, + type UsageTodayYesterdaySource, +} from "../config"; +import type { TransportRequestSummary } from "../core/transport"; +import { GO_VENDOR } from "../providerTypes"; +import { getModelMetadataSnapshot } from "../models/metadataFetcher"; +import { formatGoUsageStatusBarText } from "./formatting"; +import { GoUsageTracker, type GoUsageTrackerOptions, type UsageBaselineTargets } from "./tracker"; +import { formatUsageStatusBarText, formatUsageStatusBarTooltip, type UsageSnapshot } from "./usage"; +import { + LEGACY_FINGERPRINT, + findProfile, + keyFingerprint, + nonLegacyCount, + readMigratedTo, + readProfiles, + writeActiveProfile, + writeMigratedTo, + writeProfiles, + type UsageProfile, +} from "./usageProfile"; +import { escapeHtml, formatCount, formatRelativeTime, formatTokenCount, formatUsd } from "../utils"; + +export let usageStatusBarItem: vscode.StatusBarItem | undefined; +export let goUsageStatusBarItem: vscode.StatusBarItem | undefined; +/** Singleton tracker — the first/legacy account. Used for backward compat until first migration. */ +export let goUsageTracker: GoUsageTracker | undefined; +/** Per-profile trackers indexed by key fingerprint. */ +export const goUsageTrackers = new Map(); +/** API key per profile fingerprint — lets refreshes sync the active profile's own key. */ +export const profileApiKeys = new Map(); +export let usageWebviewPanel: vscode.WebviewPanel | undefined; + +export let profilesCache: UsageProfile[] = []; +export let activeProfileFingerprint: string = LEGACY_FINGERPRINT; + +export let _extensionContext: vscode.ExtensionContext | undefined; +export let _usageLogChannel: vscode.OutputChannel | undefined; + +/** + * Returns the extension context, or throws if the extension has not been + * activated yet. Callers must be reached after `activate()` has run. + */ +export function extensionContext(): vscode.ExtensionContext { + if (!_extensionContext) { + throw new Error("extension context not initialized"); + } + return _extensionContext; +} + +/** + * Returns the usage log output channel, or throws if the extension has not + * been activated yet. Callers must be reached after `activate()` has run. + */ +export function usageLogChannel(): vscode.OutputChannel { + if (!_usageLogChannel) { + throw new Error("usage log channel not initialized"); + } + return _usageLogChannel; +} + +export function setExtensionContext(context: vscode.ExtensionContext): void { + _extensionContext = context; +} + +export function setUsageLogChannel(channel: vscode.OutputChannel): void { + _usageLogChannel = channel; +} + +export function setGoUsageTracker(tracker: GoUsageTracker | undefined): void { + goUsageTracker = tracker; +} + +export function setProfilesCache(profiles: UsageProfile[]): void { + profilesCache = profiles; +} + +export function setActiveProfileFingerprint(fingerprint: string): void { + activeProfileFingerprint = fingerprint; +} + +export function setUsageChartWindowDays(days: number): void { + usageChartWindowDays = days; +} + +export function setUsageWebviewRendered(rendered: boolean): void { + usageWebviewRendered = rendered; +} + +/** + * Resolvers for the per-view usage knobs, read live from configuration so + * changing a setting repaints the status bar / tooltip / card immediately. + */ +export function usageTrackerOptions(): GoUsageTrackerOptions { + const config = () => vscode.workspace.getConfiguration(CONFIG_SECTION); + return { + resolveWorkspaceFolders: () => vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath) ?? [], + resolveTodayYesterdaySource: () => + config().get(SETTING_USAGE_TODAY_YESTERDAY_SOURCE, DEFAULT_USAGE_TODAY_YESTERDAY_SOURCE), + resolveCodebaseWindowDays: () => config().get(SETTING_USAGE_CODEBASE_WINDOW_DAYS, DEFAULT_USAGE_CODEBASE_WINDOW_DAYS), + resolveDayBoundary: () => config().get<"utc" | "local">(SETTING_USAGE_DAY_BOUNDARY, DEFAULT_USAGE_DAY_BOUNDARY), + }; +} + +/** Whether the detailed usage views show the server 5h rolling meter. */ +export function usageRollingMeterVisible(): boolean { + return vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get(SETTING_USAGE_ROLLING_SESSION_METER, DEFAULT_USAGE_ROLLING_SESSION_METER); +} + +/** Whether the detailed usage views show the all-time codebase row. */ +export function usageCodebaseRowVisible(): boolean { + return vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_USAGE_CODEBASE_ROW, DEFAULT_USAGE_CODEBASE_ROW); +} + +/** Every usage-view setting — a change to any of these repaints immediately. */ +export const USAGE_DISPLAY_SETTING_KEYS = [ + SETTING_USAGE_TODAY_YESTERDAY_SOURCE, + SETTING_USAGE_CODEBASE_ROW, + SETTING_USAGE_CODEBASE_WINDOW_DAYS, + SETTING_USAGE_DAY_BOUNDARY, + SETTING_USAGE_ROLLING_SESSION_METER, + SETTING_USAGE_REFRESH_INTERVAL_SECONDS, +]; + +/** + * Realtime usage updates: re-render the status bar (and webview) on a + * configurable cadence so terminal-side OpenCode CLI usage, server meters and + * day rollovers show up without waiting for the next chat request. The + * interval re-reads the setting on every tick, so changes apply live. + */ +export function startUsageRefreshLoop(context: vscode.ExtensionContext): void { + let timer: ReturnType | undefined; + const schedule = (): void => { + timer = setTimeout(() => { + refreshGoUsageStatusBar(); + schedule(); + }, usageRefreshIntervalSeconds() * 1000); + }; + schedule(); + context.subscriptions.push({ + dispose: () => { + if (timer) clearTimeout(timer); + }, + }); +} + +function usageRefreshIntervalSeconds(): number { + return Math.max( + 5, + vscode.workspace + .getConfiguration(CONFIG_SECTION) + .get(SETTING_USAGE_REFRESH_INTERVAL_SECONDS, DEFAULT_USAGE_REFRESH_INTERVAL_SECONDS), + ); +} + +/** Look up (or create) the GoUsageTracker for a given key fingerprint. */ +export function getOrCreateTracker(fingerprint: string): GoUsageTracker { + // The singleton tracker does not have a storage suffix + if (fingerprint === LEGACY_FINGERPRINT && goUsageTracker) return goUsageTracker; + let tracker = goUsageTrackers.get(fingerprint); + if (tracker) return tracker; + tracker = new GoUsageTracker( + extensionContext(), + (msg) => { + usageLogChannel().appendLine(`[${new Date().toISOString()}] [${fingerprint}] ${msg}`); + }, + (modelId) => getModelMetadataSnapshot()?.providers[GO_VENDOR]?.[modelId]?.cost, + fingerprint, + usageTrackerOptions(), + ); + goUsageTrackers.set(fingerprint, tracker); + return tracker; +} + +/** Return the tracker for the currently active profile. */ +export function activeGoUsageTracker(): GoUsageTracker | undefined { + if (activeProfileFingerprint === LEGACY_FINGERPRINT) return goUsageTracker; + return goUsageTrackers.get(activeProfileFingerprint); +} + +/** Switch the active profile and refresh the UI. */ +export async function setActiveProfile(fingerprint: string): Promise { + activeProfileFingerprint = fingerprint; + await writeActiveProfile(extensionContext(), fingerprint); + refreshGoUsageStatusBar(); + updateWebviewContent(); +} + +/** + * Ensure a profile exists in the in-memory cache for the given API key. + * This is called both from provideLanguageModelChatInformation (at startup, + * when VS Code resolves all providers) and from onTransportSummary (when + * a request completes). The first call creates the profile; subsequent + * calls are no-ops. Persistence is fire-and-forget. + */ +export function ensureProfileSync(apiKey: string): void { + const fp = keyFingerprint(apiKey); + const tracker = getOrCreateTracker(fp); + + if (!findProfile(profilesCache, fp)) { + const nextNumber = nonLegacyCount(profilesCache) + 1; + profilesCache.push({ + fingerprint: fp, + label: `Profile ${String(nextNumber)}`, + lastSeenAt: Date.now(), + }); + void writeProfiles(extensionContext(), profilesCache); + } + + // One-time migration from singleton + if (!readMigratedTo(extensionContext())) { + if (goUsageTracker && fp !== LEGACY_FINGERPRINT) { + tracker.migrateFromSingleton(); + } + void writeMigratedTo(extensionContext(), fp); + profilesCache = readProfiles(extensionContext()); + } + + // Update active profile to this one + activeProfileFingerprint = fp; + void writeActiveProfile(extensionContext(), fp); +} + +/** + * Same as ensureProfileSync, but also refreshes the UI. + * Called from onTransportSummary during request recording. + */ +export function ensureProfileForApiKey(apiKey: string): GoUsageTracker { + ensureProfileSync(apiKey); + // Remember which API key owns each profile, so status-bar refreshes can + // sync the ACTIVE profile's meters with its own key instead of the + // extension secret (which may belong to another account). + profileApiKeys.set(keyFingerprint(apiKey), apiKey); + return getOrCreateTracker(keyFingerprint(apiKey)); +} + +// ─── Status bar ────────────────────────────────────────────────────────────── + +export function ensureUsageStatusBar(context: vscode.ExtensionContext): vscode.StatusBarItem { + if (!usageStatusBarItem) { + usageStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 95); + context.subscriptions.push(usageStatusBarItem); + } + + resetUsageStatusBar(); + return usageStatusBarItem; +} + +function shouldShowUsageStatusBar(): boolean { + return vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_SHOW_USAGE_STATUS_BAR, true); +} + +export function resetUsageStatusBar(): void { + if (!usageStatusBarItem) { + return; + } + + if (!shouldShowUsageStatusBar()) { + usageStatusBarItem.hide(); + return; + } + + usageStatusBarItem.text = "OpenCode"; + usageStatusBarItem.tooltip = "OpenCode usage summary"; + usageStatusBarItem.show(); +} + +export function updateUsageStatusBar(providerDisplayName: string, modelId: string, summary: TransportRequestSummary): void { + if (!usageStatusBarItem) { + return; + } + + if (!shouldShowUsageStatusBar()) { + usageStatusBarItem.hide(); + return; + } + + const usage: UsageSnapshot = { + promptTokens: summary.promptTokens, + completionTokens: summary.completionTokens, + totalTokens: summary.totalTokens, + cachedTokens: summary.cachedTokens, + finishReason: summary.finishReason, + }; + const text = formatUsageStatusBarText(providerDisplayName, usage); + + usageStatusBarItem.text = text ?? providerDisplayName; + usageStatusBarItem.tooltip = formatUsageStatusBarTooltip(providerDisplayName, modelId, usage); + usageStatusBarItem.show(); +} + +export function ensureGoUsageStatusBar(context: vscode.ExtensionContext): void { + if (goUsageStatusBarItem) return; + goUsageStatusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 94); + goUsageStatusBarItem.command = "opencodego.showUsageQuickPick"; + context.subscriptions.push(goUsageStatusBarItem); + refreshGoUsageStatusBar(); +} + +export function refreshGoUsageStatusBar(): void { + if (!goUsageStatusBarItem) return; + const tracker = activeGoUsageTracker(); + if (!tracker) { + goUsageStatusBarItem.text = "OpenCode Go"; + goUsageStatusBarItem.tooltip = new vscode.MarkdownString(""); + goUsageStatusBarItem.show(); + return; + } + const s = tracker.getSummary(); + const activeProfile = findProfile(profilesCache, activeProfileFingerprint); + const baseText = formatGoUsageStatusBarText(s); + goUsageStatusBarItem.text = activeProfile && profilesCache.length > 1 ? `${baseText} [${activeProfile.label}]` : baseText; + goUsageStatusBarItem.tooltip = buildUsageTooltip(s); + goUsageStatusBarItem.show(); + updateWebviewContent(); + + // Refresh the server-accurate meters in the background (TTL-guarded); when + // a new snapshot lands, rebuild the status bar with it. Use the active + // profile's own key when known, falling back to the extension secret. + void (async () => { + const apiKey = profileApiKeys.get(activeProfileFingerprint) ?? (await _extensionContext?.secrets.get(secretKeyFor(GO_VENDOR))); + if (!apiKey) return; + const changed = await tracker.syncServerUsage(apiKey); + if (changed) refreshGoUsageStatusBar(); + })(); +} + +/** + * Fetch server-accurate usage for a key and repaint the status bar when a new + * snapshot arrived. Uses the tracker owning that key (creating its profile on + * first use), so multi-account setups keep per-key meters. + */ +export async function syncTrackerUsage(tracker: GoUsageTracker, apiKey: string): Promise { + const changed = await tracker.syncServerUsage(apiKey); + if (changed) refreshGoUsageStatusBar(); +} + +// ─── Usage webview panel ───────────────────────────────────────────────────── + +export function showUsageWebview(context: vscode.ExtensionContext): void { + if (usageWebviewPanel) { + usageWebviewPanel.reveal(vscode.ViewColumn.Beside); + return; + } + + usageWebviewPanel = vscode.window.createWebviewPanel("opencodego.usageWebview", "OpenCode Usage", vscode.ViewColumn.Beside, { + enableScripts: true, + retainContextWhenHidden: true, + }); + + usageWebviewPanel.onDidDispose( + () => { + usageWebviewPanel = undefined; + usageWebviewRendered = false; + }, + null, + context.subscriptions, + ); + + usageWebviewPanel.webview.onDidReceiveMessage( + (message: { type?: string }) => { + switch (message.type) { + case "refresh": + refreshGoUsageStatusBar(); + break; + case "setTargets": + void vscode.commands.executeCommand("opencodego.setUsageTargets"); + break; + case "renameProfile": + void vscode.commands.executeCommand("opencodego.renameActiveProfile"); + break; + case "window": { + const days = Number((message as { days?: unknown }).days); + if (Number.isFinite(days) && days >= 0 && days <= 370) { + usageChartWindowDays = days; + updateWebviewContent(); + } + break; + } + } + }, + null, + context.subscriptions, + ); + + usageWebviewRendered = false; + updateWebviewContent(); +} + +/** Escape a JSON payload for embedding in an HTML + + + + `; +} + +// ─── Status bar tooltip SVG ────────────────────────────────────────────────── + +function buildUsageTooltip(s: ReturnType): vscode.MarkdownString { + const md = new vscode.MarkdownString("", true); + md.supportHtml = true; + md.isTrusted = true; + const activeProfile = findProfile(profilesCache, activeProfileFingerprint); + const profileLabel = activeProfile?.label ?? "OpenCode Go"; + + // The hover shows the summary card only; Set spent targets / Rename are + // available from the Command Palette (opencodego.setUsageTargets, + // opencodego.renameActiveProfile). + md.appendMarkdown(`Go usage summary`); + return md; +} + +/** Parse a user-entered currency value. Accepts comma or dot as decimal separator. + * Returns NaN if the string contains non-numeric characters beyond the decimal separator. */ +export function parseCurrencyInput(value: string): number { + // Allow only digits, one comma or dot, and optional leading minus + if (!/^-?\d+[.,]?\d*$/.test(value)) return NaN; + return parseFloat(value.replace(",", ".")); +} + +export async function showUsageTargetEditor(tracker: GoUsageTracker): Promise { + const summary = tracker.getSummary(); + + // Ask for session spent (pre-filled with current tracked value) + const sessionStr = await vscode.window.showInputBox({ + title: "OpenCode Go — Session Spent", + prompt: `Total spent in the 5-hour rolling window (limit: $${String(GO_LIMITS.session)}).`, + placeHolder: "e.g. 3.50", + value: summary.session.spent.toFixed(2), + validateInput: (value: string) => { + const n = parseCurrencyInput(value); + if (isNaN(n) || n < 0) return "Enter a valid number using digits and . or , as decimal separator (e.g. 3.50)."; + if (n > GO_LIMITS.session) + return `Session limit is $${String(GO_LIMITS.session)}. Enter a value between 0 and ${String(GO_LIMITS.session)}.`; + return undefined; + }, + }); + if (sessionStr === undefined) return undefined; + + // Ask for weekly spent (pre-filled) + const weeklyStr = await vscode.window.showInputBox({ + title: "OpenCode Go — Weekly Spent", + prompt: `Total spent this week Mon–Mon UTC (limit: $${String(GO_LIMITS.weekly)}).`, + placeHolder: "e.g. 12.00", + value: summary.weekly.spent.toFixed(2), + validateInput: (value: string) => { + const n = parseCurrencyInput(value); + if (isNaN(n) || n < 0) return "Enter a valid number using digits and . or , as decimal separator (e.g. 12.00)."; + if (n > GO_LIMITS.weekly) + return `Weekly limit is $${String(GO_LIMITS.weekly)}. Enter a value between 0 and ${String(GO_LIMITS.weekly)}.`; + return undefined; + }, + }); + if (weeklyStr === undefined) return undefined; + + // Ask for monthly spent (pre-filled) + const monthlyStr = await vscode.window.showInputBox({ + title: "OpenCode Go — Monthly Spent", + prompt: `Total spent this month (limit: $${String(GO_LIMITS.monthly)}).`, + placeHolder: "e.g. 25.00", + value: summary.monthly.spent.toFixed(2), + validateInput: (value: string) => { + const n = parseCurrencyInput(value); + if (isNaN(n) || n < 0) return "Enter a valid number using digits and . or , as decimal separator (e.g. 25.00)."; + if (n > GO_LIMITS.monthly) + return `Monthly limit is $${String(GO_LIMITS.monthly)}. Enter a value between 0 and ${String(GO_LIMITS.monthly)}.`; + return undefined; + }, + }); + if (monthlyStr === undefined) return undefined; + + // Ask for monthly reset day (1-31) — pre-filled + const monthlyDayStr = await vscode.window.showInputBox({ + title: "OpenCode Go — Monthly Reset Day", + prompt: "Day of month when monthly usage resets (1-31). Press Enter to keep current.", + placeHolder: "e.g. 10", + value: summary.monthly.resetsAt.getUTCDate().toString(), + validateInput: (value: string) => { + if (!value) return undefined; + const n = parseInt(value, 10); + if (isNaN(n) || n < 1 || n > 31) return "Enter a day between 1 and 31."; + return undefined; + }, + }); + if (monthlyDayStr === undefined) return undefined; + + // Ask for monthly reset hour (0-23 UTC) — pre-filled + const monthlyHourStr = await vscode.window.showInputBox({ + title: "OpenCode Go — Monthly Reset Hour", + prompt: "Hour (UTC, 0-23) when monthly usage resets. Press Enter to keep current.", + placeHolder: "e.g. 0", + value: summary.monthly.resetsAt.getUTCHours().toString(), + validateInput: (value: string) => { + if (!value) return undefined; + const n = parseInt(value, 10); + if (isNaN(n) || n < 0 || n > 23) return "Enter an hour between 0 and 23 (UTC)."; + return undefined; + }, + }); + if (monthlyHourStr === undefined) return undefined; + + const monthlyAnchorDay = monthlyDayStr ? parseInt(monthlyDayStr, 10) : undefined; + const monthlyAnchorHour = monthlyHourStr ? parseInt(monthlyHourStr, 10) : undefined; + + return { + session: parseCurrencyInput(sessionStr), + weekly: parseCurrencyInput(weeklyStr), + monthly: parseCurrencyInput(monthlyStr), + monthlyAnchorDay, + monthlyAnchorHour, + }; +} + +type _UsageSummary = ReturnType; + +function usageTooltipSvgDataUri(s: _UsageSummary, profileLabel?: string): string { + const svg = buildUsageTooltipSvg(s, profileLabel); + return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`; +} + +function buildUsageTooltipSvg(s: _UsageSummary, profileLabel?: string): string { + // Stable geometry: fixed card width and fixed columns, so the layout never + // shifts when session data appears or a day has no usage yet. + const width = 440; + const padX = 14; + const right = width - padX; + const fg = "#d4d4d4"; + const muted = "#a6a6a6"; + const track = "#3c3c3c"; + const accent = "#73c991"; + const line = "#333333"; + const font = "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"; + + const svgTitle = escapeHtml(profileLabel ? `${profileLabel} - Usage` : "OpenCode Go - Usage"); + const noDataMsg = s.hasData ? null : nonLegacyCount(profilesCache) > 0 ? "No data yet for this profile." : "No usage data yet."; + + const text = (value: string, x: number, y: number, size: number, weight = 400, color = fg, anchor: "start" | "end" = "start"): string => + `${escapeHtml(value)}`; + + const bar = (pct: number, x: number, y: number, barWidth: number): string => { + const clamped = Math.min(Math.max(pct, 0), 100); + const fillWidth = Math.max(0, Math.round((clamped / 100) * barWidth)); + return [ + ``, + fillWidth > 0 ? `` : "", + ].join(""); + }; + + // Meter block with a uniform 14px gutter between blocks: label row with the + // reset time right-aligned at the card's right padding, then the bar and + // the spent/limit line below it. + const period = (label: string, p: _UsageSummary["session"], y: number): string => + [ + text(label, padX, y, 14, 700), + text(`Resets in ${formatRelativeTime(p.resetsAt)}`, right, y, 12, 400, muted, "end"), + bar(p.percent, padX, y + 14, 340), + text(`${p.percent.toFixed(1)}%`, right, y + 21, 14, 700, fg, "end"), + text(`${formatUsd(p.spent)} / ${formatUsd(p.limit)} used`, padX, y + 36, 13, 400, fg), + ].join(""); + + // Device-local rows share one fixed column grid: label, cost, requests, + // tokens. Always rendered (zeros included) so the card height is stable. + const deviceRow = (label: string, cost: number, requests: number, tokenCount: number, y: number): string => + [ + text(label, padX, y, 13, 400, muted), + text(formatUsd(cost), 120, y, 13, 700), + text("Requests:", 190, y, 13, 400, muted), + text(formatCount(requests), 262, y, 13, 700), + text("Tokens:", 305, y, 13, 400, muted), + text(formatTokenCount(tokenCount), 385, y, 13, 700), + ].join(""); + + if (!s.hasData) { + return `${text(svgTitle, padX, 28, 16, 700)} +${text(noDataMsg ?? "No usage data yet. Send a chat message to start tracking.", padX, 52, 12, 400, muted)} +`; + } + + // Title starts at the same 14px gutter as the sides. Meter rows, the + // divider and the device rows keep a consistent 14px rhythm. + const meterRows = [ + ...(usageRollingMeterVisible() ? ([["Session (5h rolling)", s.session, 56]] as const) : []), + ["Weekly", s.weekly, 116], + ["Monthly", s.monthly, 176], + ] as const; + const dividerY = 46 + meterRows.length * 60; + const firstRowY = dividerY + 22; + const rowGap = 24; + // All three rows are always rendered (zeros included) so the card is + // stable regardless of whether a session is currently active. + const deviceRows: Array<[string, number, number, number, number]> = []; + if (usageCodebaseRowVisible()) { + deviceRows.push(["Codebase:", s.codebase.cost, s.codebase.requests, s.codebase.tokens, firstRowY]); + } + const codebaseOffset = usageCodebaseRowVisible() ? 1 : 0; + deviceRows.push(["Today:", s.today.cost, s.today.requests, s.today.tokens, firstRowY + codebaseOffset * rowGap]); + deviceRows.push(["Yesterday:", s.yesterday.cost, s.yesterday.requests, s.yesterday.tokens, firstRowY + (codebaseOffset + 1) * rowGap]); + + const height = firstRowY + (deviceRows.length - 1) * rowGap + 14; + + return ` +${text(svgTitle, padX, 28, 16, 700)} +${meterRows.map(([label, periodValue, y]) => period(label, periodValue, y)).join("")} + +${deviceRows.map(([label, cost, requests, tokenCount, y]) => deviceRow(label, cost, requests, tokenCount, y)).join("")}`; +} diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts index 4f81594..1f1fd65 100644 --- a/src/usage/tracker.ts +++ b/src/usage/tracker.ts @@ -79,7 +79,7 @@ export interface UsageSummary { codebase: UsageDaily; hasData: boolean; /** When true, cost data comes from the OpenCode CLI SQLite database - (actual billed amounts). When false, costs are estimated locally. */ + (actual billed amounts). When false, costs are estimated locally. */ sqliteAvailable: boolean; } From 8b1176f0f79851dfb86748d551c5551562e7b545 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 14:56:49 +0800 Subject: [PATCH 13/22] refactor(provider): extract provider definitions into provider/definitions.ts Move the provider definition domain out of extension.ts: PROVIDERS table, ProviderDefinition / OpenCodeModel / ModelListEntry / ModelListResponse / ConvertedMessageResult / LanguageModelConfiguration types, plus the getUserAgent / isTransientFetchError helpers. extension.ts now imports from provider/definitions. Behavior-preserving; compile + 291 tests + lint green. --- src/extension.ts | 285 ++---------------------------------- src/provider/definitions.ts | 274 ++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 272 deletions(-) create mode 100644 src/provider/definitions.ts diff --git a/src/extension.ts b/src/extension.ts index 6a50051..23c33ea 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -60,8 +60,6 @@ import { DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, DEFAULT_VISION_PROXY_PROMPT, EXTENSION_ID, - FALLBACK_USER_AGENT, - FREE_ZEN_MODEL_IDS, IMAGE_TOKEN_ESTIMATE, KNOWN_UNAVAILABLE_MODEL_IDS, MAX_HISTORY_IMAGES_KEPT, @@ -81,7 +79,6 @@ import { SETTING_AUTO_ENABLE_AGENTS_WINDOW, SETTING_DEBUG_REASONING, SETTING_ENABLED, - SETTING_FREE_ONLY, SETTING_MAX_INPUT_TOKENS, SETTING_MAX_TOKENS, SETTING_REQUEST_TIMEOUT_SECONDS, @@ -161,275 +158,19 @@ import { usageTrackerOptions, } from "./usage/dashboard"; import { clearOpenCodeModelMetadataCache, getModelMetadataSnapshot, getOpenCodeModelMetadata } from "./models/metadataFetcher"; - -interface ProviderDefinition { - vendor: AllProviderVendor; - displayName: string; - modelNamePrefix: string; - modelsUrl: string; - chatCompletionsUrl: string; - messagesUrl: string; - responsesUrl?: string; - testModelId: string; - fallbackModels: string[]; - filterModel?: (modelId: string) => boolean; - /** When true, this provider only serves agent-host models (targetChatSessionType=copilotcli). */ - isAgentVariant?: boolean; - /** The vendor key for the main (non-agent) provider definition this variant mirrors. */ - baseVendor?: typeof GO_VENDOR | typeof ZEN_VENDOR; -} - -type ModelEndpointKind = "chat-completions" | "messages" | "responses" | "google"; - -let cachedUserAgent: string | undefined; - -/** - * Build the User-Agent string from the extension's declared version. - * - * CONTRACT: - * - Reads `context.extension.packageJSON.version` once, caches the result. - * - Falls back to {@link FALLBACK_USER_AGENT} when version is unavailable - * (e.g. tests that construct a stub context). - * - Avoids the drift that previously hardcoded a version literal here - * (issue #78: header reported `0.3.6` while package.json was `0.4.1`). - */ -function getUserAgent(): string { - if (cachedUserAgent) return cachedUserAgent; - const packageJSON = vscode.extensions.getExtension("ltmoerdani.opencode-copilot-chat")?.packageJSON as { version?: unknown } | undefined; - const version = typeof packageJSON?.version === "string" ? packageJSON.version : undefined; - cachedUserAgent = version ? `opencode-copilot-chat/${version} VSCode` : FALLBACK_USER_AGENT; - return cachedUserAgent; -} - -/** - * Classify a fetch error as transient (worth retrying) vs. permanent. - * - * RULES: - * - Network-layer errors (DNS, TCP reset, connect timeout, socket errors) - * are transient — undici exposes the real code via `error.cause`. - * - HTTP 4xx (except 408/429) is permanent — retrying won't help. - * - HTTP 408/429/5xx is transient — gateway/rate-limit style failures. - * These arrive via the "Model list request failed (NNN): ..." message - * that `fetchModels()` throws on a non-2xx response. - * - AbortError from a CancellationToken is NEVER retried. TimeoutError from - * AbortSignal.timeout is transient and can be retried. - */ -function isTransientFetchError(error: unknown): boolean { - // DOMException is a global since Node 17; guard anyway so a hypothetical - // older host never crashes inside error classification. - if (typeof DOMException === "function" && error instanceof DOMException) { - if (error.name === "AbortError") return false; - if (error.name === "TimeoutError") return true; - } - const cause = (error as { cause?: { code?: string; name?: string } } | undefined)?.cause; - const code = cause?.code ?? (error as { code?: string } | undefined)?.code; - const name = cause?.name ?? (error as { name?: string } | undefined)?.name; - // undici network error codes - if (code && /^E(AI_AGAIN|CONNRESET|CONNREFUSED|CONNABORTED|TIMEDOUT|HOSTUNREACH|NETUNREACH|PROTO|PIPE)$/.test(code)) { - return true; - } - if (name && /^UND_ERR_(CONNECT_TIMEOUT|SOCKET|REQUEST_TIMEOUT)$/.test(name)) { - return true; - } - // TypeError: fetch failed (the generic wrapper undici throws) — always retry; - // if the cause turns out to be non-transient, the inner check above handles it. - if (error instanceof TypeError && /fetch failed/i.test(error.message)) return true; - // Extract HTTP status from either an explicit `.status` field or the - // "Model list request failed (NNN): ..." message pattern. - const explicitStatus = (error as { status?: number } | undefined)?.status; - const msg = getErrorMessage(error); - const msgMatch = msg.match(/\((\d{3})\)/); - const httpStatus = typeof explicitStatus === "number" ? explicitStatus : msgMatch ? Number(msgMatch[1]) : undefined; - if (typeof httpStatus === "number") { - if (httpStatus === 408 || httpStatus === 429 || httpStatus >= 500) return true; - return false; - } - return false; -} - -/** Create an agent-variant provider definition that inherits URLs, models, and filters from a base. */ -function providerVariant( - base: ProviderDefinition, - agentVendor: typeof AGENT_GO_VENDOR | typeof AGENT_ZEN_VENDOR, - displayName: string, -): ProviderDefinition { - return { - vendor: agentVendor, - displayName, - modelNamePrefix: base.modelNamePrefix, - modelsUrl: base.modelsUrl, - chatCompletionsUrl: base.chatCompletionsUrl, - messagesUrl: base.messagesUrl, - responsesUrl: base.responsesUrl, - testModelId: base.testModelId, - fallbackModels: base.fallbackModels, - filterModel: base.filterModel, - }; -} - -const PROVIDERS: Record = (() => { - const go: ProviderDefinition = { - vendor: GO_VENDOR, - displayName: "OpenCode Go", - modelNamePrefix: "OpenCode Go", - modelsUrl: "https://opencode.ai/zen/go/v1/models", - chatCompletionsUrl: "https://opencode.ai/zen/go/v1/chat/completions", - messagesUrl: "https://opencode.ai/zen/go/v1/messages", - responsesUrl: "https://opencode.ai/zen/go/v1/responses", - testModelId: "deepseek-v4-flash", - fallbackModels: [ - "deepseek-v4-pro", - "deepseek-v4-flash", - "glm-5.1", - "glm-5", - "hy3-preview", - "kimi-k2.6", - "kimi-k2.5", - "mimo-v2-omni", - "mimo-v2-pro", - "mimo-v2.5", - "mimo-v2.5-pro", - "minimax-m2.7", - "minimax-m2.5", - "qwen3.7-max", - "qwen3.7-plus", - "qwen3.6-plus", - "qwen3.5-plus", - "gpt-5.6-luna", - ], - }; - const zen: ProviderDefinition = { - vendor: ZEN_VENDOR, - displayName: "OpenCode Zen", - modelNamePrefix: "OpenCode Zen", - modelsUrl: "https://opencode.ai/zen/v1/models", - chatCompletionsUrl: "https://opencode.ai/zen/v1/chat/completions", - messagesUrl: "https://opencode.ai/zen/v1/messages", - responsesUrl: "https://opencode.ai/zen/v1/responses", - testModelId: "deepseek-v4-flash-free", - fallbackModels: [ - "claude-opus-4-7", - "claude-opus-4-6", - "claude-opus-4-5", - "claude-opus-4-1", - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-sonnet-4", - "claude-haiku-4-5", - "deepseek-v4-flash-free", - "gemini-3.5-flash", - "gemini-3.1-pro", - "gemini-3-flash", - "glm-5.1", - "glm-5", - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.4", - "gpt-5.4-pro", - "gpt-5.4-mini", - "gpt-5.4-nano", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2", - "gpt-5.2-codex", - "gpt-5.1", - "gpt-5.1-codex", - "gpt-5.1-codex-max", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5-codex", - "gpt-5-nano", - "grok-build-0.1", - "kimi-k2.6", - "kimi-k2.5", - "minimax-m2.7", - "minimax-m2.5", - "minimax-m2.5-free", - "nemotron-3-super-free", - "qwen3.6-plus", - "qwen3.6-plus-free", - "qwen3.5-plus", - "big-pickle", - ], - filterModel: (modelId) => - vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_FREE_ONLY, true) - ? modelId.endsWith("-free") || FREE_ZEN_MODEL_IDS.has(modelId) - : true, - }; - return { - [GO_VENDOR]: go, - [ZEN_VENDOR]: zen, - [AGENT_GO_VENDOR]: { ...providerVariant(go, AGENT_GO_VENDOR, "OpenCode Go (Agents)"), isAgentVariant: true, baseVendor: GO_VENDOR }, - [AGENT_ZEN_VENDOR]: { - ...providerVariant(zen, AGENT_ZEN_VENDOR, "OpenCode Zen (Agents)"), - isAgentVariant: true, - baseVendor: ZEN_VENDOR, - }, - }; -})(); - -interface OpenCodeModel extends vscode.LanguageModelChatInformation { - endpointKind: ModelEndpointKind; - provider: ProviderDefinition; - rawModelId?: string; - isUserSelectable?: boolean; - configurationSchema?: vscode.LanguageModelConfigurationSchema; -} - -interface ModelListEntry { - id?: string; - owned_by?: string; - status?: string; - deprecated?: boolean; - limit?: { - context?: number; - output?: number; - }; - context_window?: number; - contextWindow?: number; - max_output_tokens?: number; - maxOutputTokens?: number; - attachment?: boolean; - image_input?: boolean; - imageInput?: boolean; - reasoning?: boolean; - modalities?: { - input?: string[]; - output?: string[]; - }; -} - -interface ModelListResponse { - data?: ModelListEntry[]; -} - -interface ConvertedMessageResult { - messages: ApiMessage[]; - normalizedImageCount: number; -} - -/** - * Reasoning effort levels per model family, sourced from the upstream - * OpenCode provider transform (anomalyco/opencode, packages/opencode/src/provider/transform.ts): - * - * WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] - * OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] - * - * For @ai-sdk/openai-compatible (Mimo, and most models routed through - * chat-completions): the default is WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]. - * DeepSeek V4 on openai-compatible additionally adds "max" → ["low", "medium", "high", "max"]. - */ -interface LanguageModelConfiguration { - apiKey?: unknown; -} - -type ConfiguredLanguageModelInfoOptions = vscode.PrepareLanguageModelChatModelOptions & { - configuration?: LanguageModelConfiguration; -}; - -type ConfiguredLanguageModelResponseOptions = vscode.ProvideLanguageModelChatResponseOptions & { - configuration?: LanguageModelConfiguration; -}; +import { + ConfiguredLanguageModelInfoOptions, + ConfiguredLanguageModelResponseOptions, + ConvertedMessageResult, + LanguageModelConfiguration, + ModelListEntry, + ModelListResponse, + OpenCodeModel, + PROVIDERS, + ProviderDefinition, + getUserAgent, + isTransientFetchError, +} from "./provider/definitions"; /** * Hard upper limit (in bytes of raw image data) for a single image embedded diff --git a/src/provider/definitions.ts b/src/provider/definitions.ts new file mode 100644 index 0000000..f18376b --- /dev/null +++ b/src/provider/definitions.ts @@ -0,0 +1,274 @@ +import * as vscode from "vscode"; +import { CONFIG_SECTION, FALLBACK_USER_AGENT, FREE_ZEN_MODEL_IDS, SETTING_FREE_ONLY } from "../config"; +import type { ApiMessage } from "../request/types"; +import { AGENT_GO_VENDOR, AGENT_ZEN_VENDOR, GO_VENDOR, ZEN_VENDOR, type AllProviderVendor } from "../providerTypes"; +import { getErrorMessage } from "../utils"; + +export interface ProviderDefinition { + vendor: AllProviderVendor; + displayName: string; + modelNamePrefix: string; + modelsUrl: string; + chatCompletionsUrl: string; + messagesUrl: string; + responsesUrl?: string; + testModelId: string; + fallbackModels: string[]; + filterModel?: (modelId: string) => boolean; + /** When true, this provider only serves agent-host models (targetChatSessionType=copilotcli). */ + isAgentVariant?: boolean; + /** The vendor key for the main (non-agent) provider definition this variant mirrors. */ + baseVendor?: typeof GO_VENDOR | typeof ZEN_VENDOR; +} + +export type ModelEndpointKind = "chat-completions" | "messages" | "responses" | "google"; + +let cachedUserAgent: string | undefined; + +/** + * Build the User-Agent string from the extension's declared version. + * + * CONTRACT: + * - Reads `context.extension.packageJSON.version` once, caches the result. + * - Falls back to {@link FALLBACK_USER_AGENT} when version is unavailable + * (e.g. tests that construct a stub context). + * - Avoids the drift that previously hardcoded a version literal here + * (issue #78: header reported `0.3.6` while package.json was `0.4.1`). + */ +export function getUserAgent(): string { + if (cachedUserAgent) return cachedUserAgent; + const packageJSON = vscode.extensions.getExtension("ltmoerdani.opencode-copilot-chat")?.packageJSON as { version?: unknown } | undefined; + const version = typeof packageJSON?.version === "string" ? packageJSON.version : undefined; + cachedUserAgent = version ? `opencode-copilot-chat/${version} VSCode` : FALLBACK_USER_AGENT; + return cachedUserAgent; +} + +/** + * Classify a fetch error as transient (worth retrying) vs. permanent. + * + * RULES: + * - Network-layer errors (DNS, TCP reset, connect timeout, socket errors) + * are transient — undici exposes the real code via `error.cause`. + * - HTTP 4xx (except 408/429) is permanent — retrying won't help. + * - HTTP 408/429/5xx is transient — gateway/rate-limit style failures. + * These arrive via the "Model list request failed (NNN): ..." message + * that `fetchModels()` throws on a non-2xx response. + * - AbortError from a CancellationToken is NEVER retried. TimeoutError from + * AbortSignal.timeout is transient and can be retried. + */ +export function isTransientFetchError(error: unknown): boolean { + // DOMException is a global since Node 17; guard anyway so a hypothetical + // older host never crashes inside error classification. + if (typeof DOMException === "function" && error instanceof DOMException) { + if (error.name === "AbortError") return false; + if (error.name === "TimeoutError") return true; + } + const cause = (error as { cause?: { code?: string; name?: string } } | undefined)?.cause; + const code = cause?.code ?? (error as { code?: string } | undefined)?.code; + const name = cause?.name ?? (error as { name?: string } | undefined)?.name; + // undici network error codes + if (code && /^E(AI_AGAIN|CONNRESET|CONNREFUSED|CONNABORTED|TIMEDOUT|HOSTUNREACH|NETUNREACH|PROTO|PIPE)$/.test(code)) { + return true; + } + if (name && /^UND_ERR_(CONNECT_TIMEOUT|SOCKET|REQUEST_TIMEOUT)$/.test(name)) { + return true; + } + // TypeError: fetch failed (the generic wrapper undici throws) — always retry; + // if the cause turns out to be non-transient, the inner check above handles it. + if (error instanceof TypeError && /fetch failed/i.test(error.message)) return true; + // Extract HTTP status from either an explicit `.status` field or the + // "Model list request failed (NNN): ..." message pattern. + const explicitStatus = (error as { status?: number } | undefined)?.status; + const msg = getErrorMessage(error); + const msgMatch = msg.match(/\((\d{3})\)/); + const httpStatus = typeof explicitStatus === "number" ? explicitStatus : msgMatch ? Number(msgMatch[1]) : undefined; + if (typeof httpStatus === "number") { + if (httpStatus === 408 || httpStatus === 429 || httpStatus >= 500) return true; + return false; + } + return false; +} + +/** Create an agent-variant provider definition that inherits URLs, models, and filters from a base. */ +function providerVariant( + base: ProviderDefinition, + agentVendor: typeof AGENT_GO_VENDOR | typeof AGENT_ZEN_VENDOR, + displayName: string, +): ProviderDefinition { + return { + vendor: agentVendor, + displayName, + modelNamePrefix: base.modelNamePrefix, + modelsUrl: base.modelsUrl, + chatCompletionsUrl: base.chatCompletionsUrl, + messagesUrl: base.messagesUrl, + responsesUrl: base.responsesUrl, + testModelId: base.testModelId, + fallbackModels: base.fallbackModels, + filterModel: base.filterModel, + }; +} + +export const PROVIDERS: Record = (() => { + const go: ProviderDefinition = { + vendor: GO_VENDOR, + displayName: "OpenCode Go", + modelNamePrefix: "OpenCode Go", + modelsUrl: "https://opencode.ai/zen/go/v1/models", + chatCompletionsUrl: "https://opencode.ai/zen/go/v1/chat/completions", + messagesUrl: "https://opencode.ai/zen/go/v1/messages", + responsesUrl: "https://opencode.ai/zen/go/v1/responses", + testModelId: "deepseek-v4-flash", + fallbackModels: [ + "deepseek-v4-pro", + "deepseek-v4-flash", + "glm-5.1", + "glm-5", + "hy3-preview", + "kimi-k2.6", + "kimi-k2.5", + "mimo-v2-omni", + "mimo-v2-pro", + "mimo-v2.5", + "mimo-v2.5-pro", + "minimax-m2.7", + "minimax-m2.5", + "qwen3.7-max", + "qwen3.7-plus", + "qwen3.6-plus", + "qwen3.5-plus", + "gpt-5.6-luna", + ], + }; + const zen: ProviderDefinition = { + vendor: ZEN_VENDOR, + displayName: "OpenCode Zen", + modelNamePrefix: "OpenCode Zen", + modelsUrl: "https://opencode.ai/zen/v1/models", + chatCompletionsUrl: "https://opencode.ai/zen/v1/chat/completions", + messagesUrl: "https://opencode.ai/zen/v1/messages", + responsesUrl: "https://opencode.ai/zen/v1/responses", + testModelId: "deepseek-v4-flash-free", + fallbackModels: [ + "claude-opus-4-7", + "claude-opus-4-6", + "claude-opus-4-5", + "claude-opus-4-1", + "claude-sonnet-4-6", + "claude-sonnet-4-5", + "claude-sonnet-4", + "claude-haiku-4-5", + "deepseek-v4-flash-free", + "gemini-3.5-flash", + "gemini-3.1-pro", + "gemini-3-flash", + "glm-5.1", + "glm-5", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.1", + "gpt-5.1-codex", + "gpt-5.1-codex-max", + "gpt-5.1-codex-mini", + "gpt-5", + "gpt-5-codex", + "gpt-5-nano", + "grok-build-0.1", + "kimi-k2.6", + "kimi-k2.5", + "minimax-m2.7", + "minimax-m2.5", + "minimax-m2.5-free", + "nemotron-3-super-free", + "qwen3.6-plus", + "qwen3.6-plus-free", + "qwen3.5-plus", + "big-pickle", + ], + filterModel: (modelId) => + vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_FREE_ONLY, true) + ? modelId.endsWith("-free") || FREE_ZEN_MODEL_IDS.has(modelId) + : true, + }; + return { + [GO_VENDOR]: go, + [ZEN_VENDOR]: zen, + [AGENT_GO_VENDOR]: { ...providerVariant(go, AGENT_GO_VENDOR, "OpenCode Go (Agents)"), isAgentVariant: true, baseVendor: GO_VENDOR }, + [AGENT_ZEN_VENDOR]: { + ...providerVariant(zen, AGENT_ZEN_VENDOR, "OpenCode Zen (Agents)"), + isAgentVariant: true, + baseVendor: ZEN_VENDOR, + }, + }; +})(); + +export interface OpenCodeModel extends vscode.LanguageModelChatInformation { + endpointKind: ModelEndpointKind; + provider: ProviderDefinition; + rawModelId?: string; + isUserSelectable?: boolean; + configurationSchema?: vscode.LanguageModelConfigurationSchema; +} + +export interface ModelListEntry { + id?: string; + owned_by?: string; + status?: string; + deprecated?: boolean; + limit?: { + context?: number; + output?: number; + }; + context_window?: number; + contextWindow?: number; + max_output_tokens?: number; + maxOutputTokens?: number; + attachment?: boolean; + image_input?: boolean; + imageInput?: boolean; + reasoning?: boolean; + modalities?: { + input?: string[]; + output?: string[]; + }; +} + +export interface ModelListResponse { + data?: ModelListEntry[]; +} + +export interface ConvertedMessageResult { + messages: ApiMessage[]; + normalizedImageCount: number; +} + +/** + * Reasoning effort levels per model family, sourced from the upstream + * OpenCode provider transform (anomalyco/opencode, packages/opencode/src/provider/transform.ts): + * + * WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"] + * OPENAI_EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh"] + * + * For @ai-sdk/openai-compatible (Mimo, and most models routed through + * chat-completions): the default is WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]. + * DeepSeek V4 on openai-compatible additionally adds "max" → ["low", "medium", "high", "max"]. + */ +export interface LanguageModelConfiguration { + apiKey?: unknown; +} + +export type ConfiguredLanguageModelInfoOptions = vscode.PrepareLanguageModelChatModelOptions & { + configuration?: LanguageModelConfiguration; +}; + +export type ConfiguredLanguageModelResponseOptions = vscode.ProvideLanguageModelChatResponseOptions & { + configuration?: LanguageModelConfiguration; +}; From b50752ad8c91aa1c5bf680ec0b6f49f63c7ce6f5 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 15:05:06 +0800 Subject: [PATCH 14/22] refactor(provider): extract message conversion + token estimation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the I partition out of extension.ts: - provider/messages.ts — convertMessage (vscode parts → wire format, incl. image normalization, MiMo tool-result flattening, reasoning echo) + normalizeMessages / trimOldImagesFromHistoryInPlace / hasMessagePayload - provider/tokens.ts — messageText / estimateChatMessageTokenCount / partToTokenCount / partToText etc. Behavior-preserving; compile + 291 tests + lint green. --- src/extension.ts | 515 +-------------------------------------- src/provider/messages.ts | 422 ++++++++++++++++++++++++++++++++ src/provider/tokens.ts | 106 ++++++++ 3 files changed, 531 insertions(+), 512 deletions(-) create mode 100644 src/provider/messages.ts create mode 100644 src/provider/tokens.ts diff --git a/src/extension.ts b/src/extension.ts index 23c33ea..699be3d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,7 +14,6 @@ import { } from "./models/metadata"; import { resolveModelRouting } from "./core/routing"; import { extractThinkingOverride, resolveThinkingConfig, thinkingFamily, thinkingProviderFor, type ThinkingSettings } from "./thinking"; -import { shouldEchoThinkingHistory, thinkingTextFromValue } from "./reasoningHistory"; import { buildOpenCodeGatewayAuthHeaders } from "./openCodeAuth"; import { streamAnthropicMessages as runStreamAnthropicMessages, @@ -33,9 +32,7 @@ import { type ProviderVendor, } from "./providerTypes"; import { providerEnabledSetting } from "./providerEnablement"; -import { isInternalDataPart, isReasoningMarkerPart, readReasoningMarker } from "./chatParts"; import { registerInlineCompletions } from "./autocomplete"; -import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "./imageNormalizer"; import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache"; import { providerModelDisplayName } from "./models/modelNames"; import { buildStableModelCapabilities } from "./models/modelCapabilities"; @@ -47,7 +44,7 @@ import { buildResponsesRequestBody, messagesHaveImages, } from "./request/builders"; -import type { ApiMessage, ApiSettings, OpenAiContentPart, OpenAiToolCall } from "./request/types"; +import type { ApiMessage, ApiSettings, OpenAiContentPart } from "./request/types"; import { runtimeDiagnosticsLines } from "./runtimeDiagnostics"; import { estimatePromptTokenCount, estimateTokenCount } from "./tokenEstimate"; import { @@ -60,12 +57,8 @@ import { DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, DEFAULT_VISION_PROXY_PROMPT, EXTENSION_ID, - IMAGE_TOKEN_ESTIMATE, KNOWN_UNAVAILABLE_MODEL_IDS, MAX_HISTORY_IMAGES_KEPT, - MAX_TOOL_RESULT_IMAGE_BYTES, - MESSAGE_NAME_TOKEN_OVERHEAD, - MESSAGE_TOKEN_OVERHEAD, MODEL_LIST_CACHE_KEY_PREFIX, MODEL_LIST_CACHE_TTL_MS, MODEL_LIST_FETCH_MAX_RETRIES, @@ -100,8 +93,6 @@ import { SUPPORT_AGENTS_WINDOW_STATE_KEY, TEST_CONNECTION_TIMEOUT_MS, THINKING_DEFAULTS, - TOOL_CALL_TOKEN_OVERHEAD, - TOOL_RESULT_TOKEN_OVERHEAD, VISION_PROXY_MODEL_ID_KEY, VISION_PROXY_PROMPT_KEY, DEFAULT_USAGE_CHART_DAYS, @@ -161,7 +152,6 @@ import { clearOpenCodeModelMetadataCache, getModelMetadataSnapshot, getOpenCodeM import { ConfiguredLanguageModelInfoOptions, ConfiguredLanguageModelResponseOptions, - ConvertedMessageResult, LanguageModelConfiguration, ModelListEntry, ModelListResponse, @@ -171,6 +161,8 @@ import { getUserAgent, isTransientFetchError, } from "./provider/definitions"; +import { convertMessage, dataPartToBase64, normalizeMessages, trimOldImagesFromHistoryInPlace } from "./provider/messages"; +import { estimateChatMessageTokenCount, messageText } from "./provider/tokens"; /** * Hard upper limit (in bytes of raw image data) for a single image embedded @@ -1970,507 +1962,6 @@ function stableHash(value: string): string { // (tool mapping + JSON-schema sanitize migrated to src/request/builders.ts) -async function convertMessage( - message: vscode.LanguageModelChatRequestMessage, - reasoningContentByToolCallId: ReadonlyMap, - rawModelId?: string, -): Promise { - const role = message.role === vscode.LanguageModelChatMessageRole.Assistant ? "assistant" : "user"; - const textParts: string[] = []; - const thinkingTextParts: string[] = []; - const imageParts: OpenAiContentPart[] = []; - const toolCalls: OpenAiToolCall[] = []; - const toolResults: ApiMessage[] = []; - let normalizedImageCount = 0; - - const normalizeImagePart = async (part: vscode.LanguageModelDataPart): Promise => { - const originalUrl = `data:${part.mimeType};base64,${dataPartToBase64(part.data)}`; - const normalizedUrl = await normalizeImageDataUrl(originalUrl); - if (normalizedUrl !== originalUrl) { - normalizedImageCount += 1; - } - return normalizedUrl; - }; - - const finish = (messages: ApiMessage[]): ConvertedMessageResult => ({ - messages, - normalizedImageCount, - }); - - for (const part of message.content) { - if (part instanceof vscode.LanguageModelToolCallPart) { - toolCalls.push({ - id: part.callId, - type: "function", - function: { - name: part.name, - arguments: JSON.stringify(part.input), - }, - }); - continue; - } - - if (part instanceof vscode.LanguageModelToolResultPart) { - // CONTRACT: A LanguageModelToolResultPart.content is unknown[] and may - // contain nested LanguageModelDataPart instances with image MIME types. - // This happens when MCP tools (e.g. chrome-devtools-mcp screenshots) - // return images. Previously we only ran partToText() which silently - // dropped image DataParts (returned "" via the catch-all fallback), - // so vision-capable models saw an empty tool result. We now serialize - // nested images into OpenAiContentPart image_url parts and emit a - // multimodal array on the tool message when any image is present. - // - // SIZE GUARD: Images larger than MAX_TOOL_RESULT_IMAGE_BYTES are - // replaced with a placeholder text part. This prevents a single - // oversized MCP screenshot from producing multi-MB payloads that - // trigger upstream 400 errors when the conversation history grows. - // Fallback for any non-text, non-image DataPart stays as plain text. - const toolTextParts: string[] = []; - const toolImageParts: OpenAiContentPart[] = []; - for (const resultPart of part.content) { - if ( - resultPart instanceof vscode.LanguageModelDataPart && - resultPart.mimeType.startsWith("image/") && - !isInternalDataPart(resultPart) - ) { - if (resultPart.data.byteLength > MAX_TOOL_RESULT_IMAGE_BYTES) { - toolTextParts.push( - `[Image attachment omitted: ${String(resultPart.data.byteLength)} bytes exceeds the ${String(MAX_TOOL_RESULT_IMAGE_BYTES)}-byte limit for tool results. Ask the tool to produce a smaller screenshot or save it to a file.]`, - ); - continue; - } - const imageUrl = await normalizeImagePart(resultPart); - toolImageParts.push({ - type: "image_url", - image_url: { url: imageUrl }, - }); - continue; - } - - const text = partToText(resultPart); - if (text) { - toolTextParts.push(text); - } - } - - let toolContent: string | OpenAiContentPart[]; - if (toolImageParts.length > 0) { - // PROVIDER QUIRK: Xiaomi MiMo (and GLM-5.2) reject list-type tool - // message content with HTTP 400 "text is not set" (upstream issue - // anomalyco/opencode#32613). MiMo accepts multimodal content in - // user/assistant messages but strictly requires `role: "tool"` - // messages to have a plain string content. The OpenCode Go gateway - // passes list-type content through unchanged, so we must flatten it - // client-side for MiMo. - // - // For MiMo: emit a plain string — join text parts, and replace each - // image with a short placeholder note (the model cannot see tool - // images on MiMo upstream anyway, so we lose nothing and gain a - // working request). For other providers: keep the multimodal array - // (Kimi, GLM-5.1, MiniMax, Qwen all accept list-type tool content). - const isMimoModel = rawModelId !== undefined && /^mimo-/i.test(rawModelId); - if (isMimoModel) { - const flattened: string[] = [...toolTextParts]; - for (let i = 0; i < toolImageParts.length; i++) { - flattened.push( - `[Tool returned an image attachment, but the MiMo upstream provider does not accept images in tool messages. Image ${String(i + 1)} of ${String(toolImageParts.length)} was dropped to keep the request valid.]`, - ); - } - toolContent = flattened.join("\n"); - } else { - const multimodal: OpenAiContentPart[] = []; - const joinedText = toolTextParts.join("\n"); - if (joinedText) { - multimodal.push({ type: "text", text: joinedText }); - } - multimodal.push(...toolImageParts); - toolContent = multimodal; - } - } else { - toolContent = toolTextParts.join("\n"); - } - - toolResults.push({ - role: "tool", - tool_call_id: part.callId, - content: toolContent, - }); - continue; - } - - if (part instanceof vscode.LanguageModelDataPart && part.mimeType.startsWith("image/")) { - // Normalize before the final payload guard. The previous raw-byte guard - // ran first and dropped images that could have been resized or compressed - // into a provider-safe representation. - const imageUrl = await normalizeImagePart(part); - const base64Bytes = getImageDataUrlBase64Bytes(imageUrl); - if (base64Bytes === undefined || base64Bytes > MAX_IMAGE_BASE64_BYTES) { - textParts.push( - `[Image attachment omitted: normalized payload exceeds the ` + - `${String(Math.floor(MAX_IMAGE_BASE64_BYTES / (1024 * 1024)))} MB base64 limit. ` + - `Resize or compress the image and re-attach it.]`, - ); - continue; - } - imageParts.push({ - type: "image_url", - image_url: { url: imageUrl }, - }); - continue; - } - - if (part instanceof vscode.LanguageModelDataPart && isInternalDataPart(part)) { - continue; - } - - if (part instanceof vscode.LanguageModelThinkingPart) { - const thinking = thinkingPartText(part); - if (thinking) { - thinkingTextParts.push(thinking); - } - continue; - } - - if (part instanceof vscode.LanguageModelDataPart && isReasoningMarkerPart(part)) { - // Thinking-off responses carry their reasoning in a marker data part - // (see streaming.ts / gateway bug #37635); echo it as reasoning_content - // on the next turn or DeepSeek's validator 400s. - const reasoning = readReasoningMarker(part); - if (reasoning) { - thinkingTextParts.push(reasoning); - } - continue; - } - - const text = partToText(part); - if (text) { - textParts.push(text); - } - } - - // Build content: use multimodal array if images present, otherwise plain string - const hasImages = imageParts.length > 0; - const textContent = textParts.join("\n"); - - // Thinking parts from the conversation history. Models like DeepSeek V4 - // (OpenAI-compatible chat-completions) REQUIRE the previously emitted - // reasoning_content to be passed back unchanged on multi-turn requests — - // omitting it yields HTTP 400 "The reasoning_content in the thinking mode - // must be passed back to the API". This also enables cross-turn reasoning - // continuity for other families (Kimi, GLM, Qwen, MiniMax, Gemini). - const thinkingText = thinkingTextParts.length ? thinkingTextParts.join("\n").trim() : undefined; - - let content: string | null | OpenAiContentPart[] = textContent; - if (hasImages) { - const multimodal: OpenAiContentPart[] = []; - if (textContent) { - multimodal.push({ type: "text", text: textContent }); - } - multimodal.push(...imageParts); - content = multimodal; - } - - if (role === "assistant" && toolCalls.length) { - // CONTRACT: reasoning_content injection into tool_call assistant messages - // is gated by model family. MiMo upstream (Xiaomi) uses a strict Pydantic- - // style validator that rejects assistant tool_call messages carrying a - // `reasoning_content` field with HTTP 400 `Upstream request failed`, once - // the conversation history contains tool_calls with reasoning echo. This - // mirrors the DeepSeek V4 issue (#36354 upstream) and was verified in this - // extension's logs (issue #38, 2026-07-25): MiMo succeeds until the first - // tool_call turn with reasoning_content, then every subsequent turn 400s. - // - // For MiMo we omit reasoning_content in the echoed assistant tool_call - // history. The current live response still surfaces reasoning_content to - // the user via the thinking panel — only the *history echo* is dropped. - // Other families (DeepSeek, Kimi, GLM, Qwen, MiniMax) tolerate the echo - // and keep it for cross-turn reasoning continuity. - const shouldOmitReasoningEcho = rawModelId !== undefined && /^mimo-/i.test(rawModelId); - return finish([ - { - role, - content: typeof content === "string" ? content || null : content, - reasoning_content: shouldOmitReasoningEcho - ? undefined - : (reasoningForToolCalls(toolCalls, reasoningContentByToolCallId) ?? thinkingText), - tool_calls: toolCalls, - }, - ]); - } - - if (toolResults.length) { - return finish(content ? [{ role, content }, ...toolResults] : toolResults); - } - - if (role === "assistant") { - return finish([ - { - role, - content: typeof content === "string" ? content || null : content, - reasoning_content: shouldEchoThinkingHistory(rawModelId) ? thinkingText : undefined, - }, - ]); - } - - return finish([{ role, content }]); -} - -function dataPartToBase64(data: Uint8Array): string { - return Buffer.from(data).toString("base64"); -} - -function reasoningForToolCalls(toolCalls: OpenAiToolCall[], reasoningContentByToolCallId: ReadonlyMap): string | undefined { - const reasoning = toolCalls - .map((toolCall) => reasoningContentByToolCallId.get(toolCall.id)) - .filter((value): value is string => Boolean(value?.trim())); - - return reasoning.length ? reasoning.join("\n") : undefined; -} - -/** - * Extract the raw thinking text from a history `LanguageModelThinkingPart`. - * `LanguageModelThinkingPart` is a proposed VS Code API available at runtime - * on all hosts we target (^1.125.0); `partToText` intentionally ignores it so - * the thinking text never leaks into the visible assistant `content`. The - * `typeof` guard mirrors `streaming.ts` so we degrade gracefully on any - * hypothetical older host. - */ -function thinkingPartText(part: unknown): string { - if (typeof vscode.LanguageModelThinkingPart !== "function" || !(part instanceof vscode.LanguageModelThinkingPart)) { - return ""; - } - return thinkingTextFromValue(part.value); -} - -function messageText(message: vscode.LanguageModelChatRequestMessage): string { - return message.content.map(partToText).filter(Boolean).join("\n"); -} - -function estimateChatMessageTokenCount(message: vscode.LanguageModelChatRequestMessage): number { - const role = typeof message.role === "string" ? message.role : String(message.role); - const name = typeof message.name === "string" ? message.name : ""; - const contentTokens = message.content.map(partToTokenCount).reduce((total, count) => total + count, 0); - - return ( - MESSAGE_TOKEN_OVERHEAD + estimateTokenCount(role) + (name ? MESSAGE_NAME_TOKEN_OVERHEAD + estimateTokenCount(name) : 0) + contentTokens - ); -} - -function partToTokenCount(part: unknown): number { - if (part instanceof vscode.LanguageModelTextPart) { - return estimateTokenCount(part.value); - } - - if (part instanceof vscode.LanguageModelToolResultPart) { - const contentTokens = part.content.map(partToTokenCount).reduce((total, count) => total + count, 0); - return TOOL_RESULT_TOKEN_OVERHEAD + estimateTokenCount(part.callId) + contentTokens; - } - - if (part instanceof vscode.LanguageModelToolCallPart) { - return ( - TOOL_CALL_TOKEN_OVERHEAD + estimateTokenCount(part.callId) + estimateTokenCount(part.name) + estimateStructuredTokenCount(part.input) - ); - } - - if (part instanceof vscode.LanguageModelDataPart) { - return isInternalDataPart(part) ? 0 : estimateDataPartTokenCount(part); - } - - if (typeof part === "string") { - return estimateTokenCount(part); - } - - if (isRecord(part)) { - return estimateStructuredTokenCount(part); - } - - return 0; -} - -function estimateStructuredTokenCount(value: unknown): number { - try { - return estimateTokenCount(JSON.stringify(value)); - } catch { - return 0; - } -} - -function estimateDataPartTokenCount(part: vscode.LanguageModelDataPart): number { - if (part.mimeType.startsWith("image/")) { - return IMAGE_TOKEN_ESTIMATE; - } - - if (part.mimeType.startsWith("text/") || part.mimeType === "application/json") { - return estimateTokenCount(new TextDecoder().decode(part.data)); - } - - return Math.max(1, Math.ceil(part.data.byteLength / 4)); -} - -function partToText(part: unknown): string { - if (part instanceof vscode.LanguageModelTextPart) { - return part.value; - } - - if (part instanceof vscode.LanguageModelToolResultPart) { - return part.content.map(partToText).filter(Boolean).join("\n"); - } - - if (part instanceof vscode.LanguageModelToolCallPart) { - return `[Tool call: ${part.name} ${JSON.stringify(part.input)}]`; - } - - if (part instanceof vscode.LanguageModelDataPart && isInternalDataPart(part)) { - return ""; - } - - if (typeof part === "string") { - return part; - } - - return ""; -} - -function normalizeMessages(messages: ApiMessage[]): ApiMessage[] { - const normalized: ApiMessage[] = []; - - for (const message of messages) { - if (!hasMessagePayload(message)) { - continue; - } - - const previous = normalized.at(-1); - const prevContent = previous?.content; - const msgContent = message.content; - const prevIsString = typeof prevContent === "string"; - const msgIsString = typeof msgContent === "string"; - const prevHasToolCalls = !!(previous?.tool_calls?.length || previous?.tool_call_id); - const msgHasToolCalls = !!(message.tool_calls?.length || message.tool_call_id); - - if ( - previous?.role === message.role && - message.role !== "tool" && - prevIsString && - msgIsString && - !prevHasToolCalls && - !msgHasToolCalls - ) { - previous.content = `${prevContent}\n\n${msgContent}`.trim(); - } else { - normalized.push({ ...message }); - } - } - - if (normalized[0]?.role === "assistant") { - normalized.unshift({ - role: "user", - content: "Continue the conversation based on the prior assistant message.", - }); - } - - return normalized.length ? normalized : [{ role: "user", content: "" }]; -} - -// messagesHaveImages migrated to src/request/builders.ts - -/** - * Replace image content parts in older messages with a placeholder text note - * in place, keeping only the most recent `MAX_HISTORY_IMAGES_KEPT` images in - * the conversation. This bounds the cumulative payload weight when MCP - * screenshot loops (chrome-devtools-mcp, playwright-mcp) accumulate base64 - * data URIs in history and trigger upstream `400 Upstream request failed` - * rejections from OpenCode Go. - * - * CONTRACT: - * - Iterates messages from newest to oldest, counting `image_url` parts. - * - Once `MAX_HISTORY_IMAGES_KEPT` images have been seen, every subsequent - * (older) image part is replaced in place with a placeholder text note. - * - Non-image content parts (text, tool_calls, tool_call_id) are preserved - * unchanged — the conversation structure stays intact. - * - The placeholder replaces the image part in the same message's content - * array; the array shape is preserved so downstream transport builders - * still see a valid multimodal structure. - * - Mutates the input array's message `content` fields in place (safe: the - * caller `provideLanguageModelChatResponse` does not reuse the original - * array after this point). - * - * INVARIANTS: - * - Total `image_url` parts remaining in the array after the call ≤ - * `MAX_HISTORY_IMAGES_KEPT`. - * - Every original image position is either preserved or replaced with a - * placeholder text part — no message is silently dropped. - * - * @param messages ApiMessage[] from convertMessage() — must be in chronological - * order (oldest first, newest last), as produced by - * `messages.flatMap(convertMessage)`. Mutated in place. - * @returns Number of image parts that were replaced with a placeholder (for - * diagnostic logging). Returns 0 when no trimming was needed. - */ -function trimOldImagesFromHistoryInPlace(messages: ApiMessage[]): number { - // Count total images to decide whether trimming is needed. Cheap pass that - // skips allocation and mutation for the common case (short conversations, - // 0-2 images). - let totalImages = 0; - for (const msg of messages) { - if (!Array.isArray(msg.content)) continue; - for (const part of msg.content) { - if (part.type === "image_url") totalImages++; - } - } - if (totalImages <= MAX_HISTORY_IMAGES_KEPT) { - return 0; - } - - // Walk newest -> oldest, allowing the first MAX_HISTORY_IMAGES_KEPT images - // to pass through and replacing every older image with a placeholder note. - let imagesKept = 0; - let replacedCount = 0; - for (let i = messages.length - 1; i >= 0; i--) { - const msg = messages[i]; - if (!Array.isArray(msg.content)) continue; - const hasImage = msg.content.some((p) => p.type === "image_url"); - if (!hasImage) continue; - // Build a new content array, replacing image parts once the budget is spent. - // We rebuild the array rather than splice-in-place because the original - // parts array may be shared with the caller's view. - const newContent: OpenAiContentPart[] = []; - for (const part of msg.content) { - if (part.type === "image_url") { - if (imagesKept < MAX_HISTORY_IMAGES_KEPT) { - newContent.push(part); - imagesKept++; - } else { - newContent.push({ - type: "text", - text: "[Earlier screenshot omitted from history to keep request payload under gateway limit. The latest screenshots above are preserved.]", - }); - replacedCount++; - } - } else { - newContent.push(part); - } - } - msg.content = newContent; - } - return replacedCount; -} - -function hasMessagePayload(message: ApiMessage): boolean { - if (message.tool_calls?.length || message.tool_call_id) { - return true; - } - - if (typeof message.content === "string") { - return message.content.trim().length > 0; - } - - if (Array.isArray(message.content)) { - return message.content.length > 0; - } - - return false; -} - // Detect which Thinking family a raw model id belongs to. Used both to render // the per-model picker submenu (configurationSchema) and to map the user's // per-request selection back to the right OpenCode request field. diff --git a/src/provider/messages.ts b/src/provider/messages.ts new file mode 100644 index 0000000..aae75bf --- /dev/null +++ b/src/provider/messages.ts @@ -0,0 +1,422 @@ +import * as vscode from "vscode"; +import { isInternalDataPart, isReasoningMarkerPart, readReasoningMarker } from "../chatParts"; +import { MAX_HISTORY_IMAGES_KEPT, MAX_TOOL_RESULT_IMAGE_BYTES } from "../config"; +import { getImageDataUrlBase64Bytes, MAX_IMAGE_BASE64_BYTES, normalizeImageDataUrl } from "../imageNormalizer"; +import { shouldEchoThinkingHistory, thinkingTextFromValue } from "../reasoningHistory"; +import type { ApiMessage, OpenAiContentPart, OpenAiToolCall } from "../request/types"; +import { partToText } from "./tokens"; +import type { ConvertedMessageResult } from "./definitions"; + +/** Convert a VS Code chat message (text/tool/data/thinking parts) into the wire format. */ +export async function convertMessage( + message: vscode.LanguageModelChatRequestMessage, + reasoningContentByToolCallId: ReadonlyMap, + rawModelId?: string, +): Promise { + const role = message.role === vscode.LanguageModelChatMessageRole.Assistant ? "assistant" : "user"; + const textParts: string[] = []; + const thinkingTextParts: string[] = []; + const imageParts: OpenAiContentPart[] = []; + const toolCalls: OpenAiToolCall[] = []; + const toolResults: ApiMessage[] = []; + let normalizedImageCount = 0; + + const normalizeImagePart = async (part: vscode.LanguageModelDataPart): Promise => { + const originalUrl = `data:${part.mimeType};base64,${dataPartToBase64(part.data)}`; + const normalizedUrl = await normalizeImageDataUrl(originalUrl); + if (normalizedUrl !== originalUrl) { + normalizedImageCount += 1; + } + return normalizedUrl; + }; + + const finish = (messages: ApiMessage[]): ConvertedMessageResult => ({ + messages, + normalizedImageCount, + }); + + for (const part of message.content) { + if (part instanceof vscode.LanguageModelToolCallPart) { + toolCalls.push({ + id: part.callId, + type: "function", + function: { + name: part.name, + arguments: JSON.stringify(part.input), + }, + }); + continue; + } + + if (part instanceof vscode.LanguageModelToolResultPart) { + // CONTRACT: A LanguageModelToolResultPart.content is unknown[] and may + // contain nested LanguageModelDataPart instances with image MIME types. + // This happens when MCP tools (e.g. chrome-devtools-mcp screenshots) + // return images. Previously we only ran partToText() which silently + // dropped image DataParts (returned "" via the catch-all fallback), + // so vision-capable models saw an empty tool result. We now serialize + // nested images into OpenAiContentPart image_url parts and emit a + // multimodal array on the tool message when any image is present. + // + // SIZE GUARD: Images larger than MAX_TOOL_RESULT_IMAGE_BYTES are + // replaced with a placeholder text part. This prevents a single + // oversized MCP screenshot from producing multi-MB payloads that + // trigger upstream 400 errors when the conversation history grows. + // Fallback for any non-text, non-image DataPart stays as plain text. + const toolTextParts: string[] = []; + const toolImageParts: OpenAiContentPart[] = []; + for (const resultPart of part.content) { + if ( + resultPart instanceof vscode.LanguageModelDataPart && + resultPart.mimeType.startsWith("image/") && + !isInternalDataPart(resultPart) + ) { + if (resultPart.data.byteLength > MAX_TOOL_RESULT_IMAGE_BYTES) { + toolTextParts.push( + `[Image attachment omitted: ${String(resultPart.data.byteLength)} bytes exceeds the ${String(MAX_TOOL_RESULT_IMAGE_BYTES)}-byte limit for tool results. Ask the tool to produce a smaller screenshot or save it to a file.]`, + ); + continue; + } + const imageUrl = await normalizeImagePart(resultPart); + toolImageParts.push({ + type: "image_url", + image_url: { url: imageUrl }, + }); + continue; + } + + const text = partToText(resultPart); + if (text) { + toolTextParts.push(text); + } + } + + let toolContent: string | OpenAiContentPart[]; + if (toolImageParts.length > 0) { + // PROVIDER QUIRK: Xiaomi MiMo (and GLM-5.2) reject list-type tool + // message content with HTTP 400 "text is not set" (upstream issue + // anomalyco/opencode#32613). MiMo accepts multimodal content in + // user/assistant messages but strictly requires `role: "tool"` + // messages to have a plain string content. The OpenCode Go gateway + // passes list-type content through unchanged, so we must flatten it + // client-side for MiMo. + // + // For MiMo: emit a plain string — join text parts, and replace each + // image with a short placeholder note (the model cannot see tool + // images on MiMo upstream anyway, so we lose nothing and gain a + // working request). For other providers: keep the multimodal array + // (Kimi, GLM-5.1, MiniMax, Qwen all accept list-type tool content). + const isMimoModel = rawModelId !== undefined && /^mimo-/i.test(rawModelId); + if (isMimoModel) { + const flattened: string[] = [...toolTextParts]; + for (let i = 0; i < toolImageParts.length; i++) { + flattened.push( + `[Tool returned an image attachment, but the MiMo upstream provider does not accept images in tool messages. Image ${String(i + 1)} of ${String(toolImageParts.length)} was dropped to keep the request valid.]`, + ); + } + toolContent = flattened.join("\n"); + } else { + const multimodal: OpenAiContentPart[] = []; + const joinedText = toolTextParts.join("\n"); + if (joinedText) { + multimodal.push({ type: "text", text: joinedText }); + } + multimodal.push(...toolImageParts); + toolContent = multimodal; + } + } else { + toolContent = toolTextParts.join("\n"); + } + + toolResults.push({ + role: "tool", + tool_call_id: part.callId, + content: toolContent, + }); + continue; + } + + if (part instanceof vscode.LanguageModelDataPart && part.mimeType.startsWith("image/")) { + // Normalize before the final payload guard. The previous raw-byte guard + // ran first and dropped images that could have been resized or compressed + // into a provider-safe representation. + const imageUrl = await normalizeImagePart(part); + const base64Bytes = getImageDataUrlBase64Bytes(imageUrl); + if (base64Bytes === undefined || base64Bytes > MAX_IMAGE_BASE64_BYTES) { + textParts.push( + `[Image attachment omitted: normalized payload exceeds the ` + + `${String(Math.floor(MAX_IMAGE_BASE64_BYTES / (1024 * 1024)))} MB base64 limit. ` + + `Resize or compress the image and re-attach it.]`, + ); + continue; + } + imageParts.push({ + type: "image_url", + image_url: { url: imageUrl }, + }); + continue; + } + + if (part instanceof vscode.LanguageModelDataPart && isInternalDataPart(part)) { + continue; + } + + if (part instanceof vscode.LanguageModelThinkingPart) { + const thinking = thinkingPartText(part); + if (thinking) { + thinkingTextParts.push(thinking); + } + continue; + } + + if (part instanceof vscode.LanguageModelDataPart && isReasoningMarkerPart(part)) { + // Thinking-off responses carry their reasoning in a marker data part + // (see streaming.ts / gateway bug #37635); echo it as reasoning_content + // on the next turn or DeepSeek's validator 400s. + const reasoning = readReasoningMarker(part); + if (reasoning) { + thinkingTextParts.push(reasoning); + } + continue; + } + + const text = partToText(part); + if (text) { + textParts.push(text); + } + } + + // Build content: use multimodal array if images present, otherwise plain string + const hasImages = imageParts.length > 0; + const textContent = textParts.join("\n"); + + // Thinking parts from the conversation history. Models like DeepSeek V4 + // (OpenAI-compatible chat-completions) REQUIRE the previously emitted + // reasoning_content to be passed back unchanged on multi-turn requests — + // omitting it yields HTTP 400 "The reasoning_content in the thinking mode + // must be passed back to the API". This also enables cross-turn reasoning + // continuity for other families (Kimi, GLM, Qwen, MiniMax, Gemini). + const thinkingText = thinkingTextParts.length ? thinkingTextParts.join("\n").trim() : undefined; + + let content: string | null | OpenAiContentPart[] = textContent; + if (hasImages) { + const multimodal: OpenAiContentPart[] = []; + if (textContent) { + multimodal.push({ type: "text", text: textContent }); + } + multimodal.push(...imageParts); + content = multimodal; + } + + if (role === "assistant" && toolCalls.length) { + // CONTRACT: reasoning_content injection into tool_call assistant messages + // is gated by model family. MiMo upstream (Xiaomi) uses a strict Pydantic- + // style validator that rejects assistant tool_call messages carrying a + // `reasoning_content` field with HTTP 400 `Upstream request failed`, once + // the conversation history contains tool_calls with reasoning echo. This + // mirrors the DeepSeek V4 issue (#36354 upstream) and was verified in this + // extension's logs (issue #38, 2026-07-25): MiMo succeeds until the first + // tool_call turn with reasoning_content, then every subsequent turn 400s. + // + // For MiMo we omit reasoning_content in the echoed assistant tool_call + // history. The current live response still surfaces reasoning_content to + // the user via the thinking panel — only the *history echo* is dropped. + // Other families (DeepSeek, Kimi, GLM, Qwen, MiniMax) tolerate the echo + // and keep it for cross-turn reasoning continuity. + const shouldOmitReasoningEcho = rawModelId !== undefined && /^mimo-/i.test(rawModelId); + return finish([ + { + role, + content: typeof content === "string" ? content || null : content, + reasoning_content: shouldOmitReasoningEcho + ? undefined + : (reasoningForToolCalls(toolCalls, reasoningContentByToolCallId) ?? thinkingText), + tool_calls: toolCalls, + }, + ]); + } + + if (toolResults.length) { + return finish(content ? [{ role, content }, ...toolResults] : toolResults); + } + + if (role === "assistant") { + return finish([ + { + role, + content: typeof content === "string" ? content || null : content, + reasoning_content: shouldEchoThinkingHistory(rawModelId) ? thinkingText : undefined, + }, + ]); + } + + return finish([{ role, content }]); +} + +export function dataPartToBase64(data: Uint8Array): string { + return Buffer.from(data).toString("base64"); +} + +export function reasoningForToolCalls( + toolCalls: OpenAiToolCall[], + reasoningContentByToolCallId: ReadonlyMap, +): string | undefined { + const reasoning = toolCalls + .map((toolCall) => reasoningContentByToolCallId.get(toolCall.id)) + .filter((value): value is string => Boolean(value?.trim())); + + return reasoning.length ? reasoning.join("\n") : undefined; +} + +/** + * Extract the raw thinking text from a history `LanguageModelThinkingPart`. + * `LanguageModelThinkingPart` is a proposed VS Code API available at runtime + * on all hosts we target (^1.125.0); `partToText` intentionally ignores it so + * the thinking text never leaks into the visible assistant `content`. The + * `typeof` guard mirrors `streaming.ts` so we degrade gracefully on any + * hypothetical older host. + */ +export function thinkingPartText(part: unknown): string { + if (typeof vscode.LanguageModelThinkingPart !== "function" || !(part instanceof vscode.LanguageModelThinkingPart)) { + return ""; + } + return thinkingTextFromValue(part.value); +} + +export function normalizeMessages(messages: ApiMessage[]): ApiMessage[] { + const normalized: ApiMessage[] = []; + + for (const message of messages) { + if (!hasMessagePayload(message)) { + continue; + } + + const previous = normalized.at(-1); + const prevContent = previous?.content; + const msgContent = message.content; + const prevIsString = typeof prevContent === "string"; + const msgIsString = typeof msgContent === "string"; + const prevHasToolCalls = !!(previous?.tool_calls?.length || previous?.tool_call_id); + const msgHasToolCalls = !!(message.tool_calls?.length || message.tool_call_id); + + if ( + previous?.role === message.role && + message.role !== "tool" && + prevIsString && + msgIsString && + !prevHasToolCalls && + !msgHasToolCalls + ) { + previous.content = `${prevContent}\n\n${msgContent}`.trim(); + } else { + normalized.push({ ...message }); + } + } + + if (normalized[0]?.role === "assistant") { + normalized.unshift({ + role: "user", + content: "Continue the conversation based on the prior assistant message.", + }); + } + + return normalized.length ? normalized : [{ role: "user", content: "" }]; +} + +/** + * Replace image content parts in older messages with a placeholder text note + * in place, keeping only the most recent `MAX_HISTORY_IMAGES_KEPT` images in + * the conversation. This bounds the cumulative payload weight when MCP + * screenshot loops (chrome-devtools-mcp, playwright-mcp) accumulate base64 + * data URIs in history and trigger upstream `400 Upstream request failed` + * rejections from OpenCode Go. + * + * CONTRACT: + * - Iterates messages from newest to oldest, counting `image_url` parts. + * - Once `MAX_HISTORY_IMAGES_KEPT` images have been seen, every subsequent + * (older) image part is replaced in place with a placeholder text note. + * - Non-image content parts (text, tool_calls, tool_call_id) are preserved + * unchanged — the conversation structure stays intact. + * - The placeholder replaces the image part in the same message's content + * array; the array shape is preserved so downstream transport builders + * still see a valid multimodal structure. + * - Mutates the input array's message `content` fields in place (safe: the + * caller `provideLanguageModelChatResponse` does not reuse the original + * array after this point). + * + * INVARIANTS: + * - Total `image_url` parts remaining in the array after the call ≤ + * `MAX_HISTORY_IMAGES_KEPT`. + * - Every original image position is either preserved or replaced with a + * placeholder text part — no message is silently dropped. + * + * @param messages ApiMessage[] from convertMessage() — must be in chronological + * order (oldest first, newest last), as produced by + * `messages.flatMap(convertMessage)`. Mutated in place. + * @returns Number of image parts that were replaced with a placeholder (for + * diagnostic logging). Returns 0 when no trimming was needed. + */ +export function trimOldImagesFromHistoryInPlace(messages: ApiMessage[]): number { + // Count total images to decide whether trimming is needed. Cheap pass that + // skips allocation and mutation for the common case (short conversations, + // 0-2 images). + let totalImages = 0; + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue; + for (const part of msg.content) { + if (part.type === "image_url") totalImages++; + } + } + if (totalImages <= MAX_HISTORY_IMAGES_KEPT) { + return 0; + } + + // Walk newest -> oldest, allowing the first MAX_HISTORY_IMAGES_KEPT images + // to pass through and replacing every older image with a placeholder note. + let imagesKept = 0; + let replacedCount = 0; + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]; + if (!Array.isArray(msg.content)) continue; + const hasImage = msg.content.some((p) => p.type === "image_url"); + if (!hasImage) continue; + // Build a new content array, replacing image parts once the budget is spent. + // We rebuild the array rather than splice-in-place because the original + // parts array may be shared with the caller's view. + const newContent: OpenAiContentPart[] = []; + for (const part of msg.content) { + if (part.type === "image_url") { + if (imagesKept < MAX_HISTORY_IMAGES_KEPT) { + newContent.push(part); + imagesKept++; + } else { + newContent.push({ + type: "text", + text: "[Earlier screenshot omitted from history to keep request payload under gateway limit. The latest screenshots above are preserved.]", + }); + replacedCount++; + } + } else { + newContent.push(part); + } + } + msg.content = newContent; + } + return replacedCount; +} + +export function hasMessagePayload(message: ApiMessage): boolean { + if (message.tool_calls?.length || message.tool_call_id) { + return true; + } + + if (typeof message.content === "string") { + return message.content.trim().length > 0; + } + + if (Array.isArray(message.content)) { + return message.content.length > 0; + } + + return false; +} diff --git a/src/provider/tokens.ts b/src/provider/tokens.ts new file mode 100644 index 0000000..79e1da0 --- /dev/null +++ b/src/provider/tokens.ts @@ -0,0 +1,106 @@ +import * as vscode from "vscode"; +import { + MESSAGE_NAME_TOKEN_OVERHEAD, + MESSAGE_TOKEN_OVERHEAD, + TOOL_CALL_TOKEN_OVERHEAD, + TOOL_RESULT_TOKEN_OVERHEAD, + IMAGE_TOKEN_ESTIMATE, +} from "../config"; +import { estimateTokenCount } from "../tokenEstimate"; +import { isInternalDataPart } from "../chatParts"; +import { isRecord } from "../utils"; + +/** Extract the visible text of a chat message (all parts joined). */ +export function messageText(message: vscode.LanguageModelChatRequestMessage): string { + return message.content.map(partToText).filter(Boolean).join("\n"); +} + +/** Token estimate for a whole chat message (role/name overhead + content). */ +export function estimateChatMessageTokenCount(message: vscode.LanguageModelChatRequestMessage): number { + const role = typeof message.role === "string" ? message.role : String(message.role); + const name = typeof message.name === "string" ? message.name : ""; + const contentTokens = message.content.map(partToTokenCount).reduce((total, count) => total + count, 0); + + return ( + MESSAGE_TOKEN_OVERHEAD + estimateTokenCount(role) + (name ? MESSAGE_NAME_TOKEN_OVERHEAD + estimateTokenCount(name) : 0) + contentTokens + ); +} + +/** Token estimate for a single response part. */ +export function partToTokenCount(part: unknown): number { + if (part instanceof vscode.LanguageModelTextPart) { + return estimateTokenCount(part.value); + } + + if (part instanceof vscode.LanguageModelToolResultPart) { + const contentTokens = part.content.map(partToTokenCount).reduce((total, count) => total + count, 0); + return TOOL_RESULT_TOKEN_OVERHEAD + estimateTokenCount(part.callId) + contentTokens; + } + + if (part instanceof vscode.LanguageModelToolCallPart) { + return ( + TOOL_CALL_TOKEN_OVERHEAD + estimateTokenCount(part.callId) + estimateTokenCount(part.name) + estimateStructuredTokenCount(part.input) + ); + } + + if (part instanceof vscode.LanguageModelDataPart) { + return isInternalDataPart(part) ? 0 : estimateDataPartTokenCount(part); + } + + if (typeof part === "string") { + return estimateTokenCount(part); + } + + if (isRecord(part)) { + return estimateStructuredTokenCount(part); + } + + return 0; +} + +/** Token estimate for an arbitrary structured value (JSON-serialized). */ +export function estimateStructuredTokenCount(value: unknown): number { + try { + return estimateTokenCount(JSON.stringify(value)); + } catch { + return 0; + } +} + +/** Token estimate for a data part (images use a fixed per-image estimate). */ +export function estimateDataPartTokenCount(part: vscode.LanguageModelDataPart): number { + if (part.mimeType.startsWith("image/")) { + return IMAGE_TOKEN_ESTIMATE; + } + + if (part.mimeType.startsWith("text/") || part.mimeType === "application/json") { + return estimateTokenCount(new TextDecoder().decode(part.data)); + } + + return Math.max(1, Math.ceil(part.data.byteLength / 4)); +} + +/** Plain-text serialization of a response part (internal data parts → ""). */ +export function partToText(part: unknown): string { + if (part instanceof vscode.LanguageModelTextPart) { + return part.value; + } + + if (part instanceof vscode.LanguageModelToolResultPart) { + return part.content.map(partToText).filter(Boolean).join("\n"); + } + + if (part instanceof vscode.LanguageModelToolCallPart) { + return `[Tool call: ${part.name} ${JSON.stringify(part.input)}]`; + } + + if (part instanceof vscode.LanguageModelDataPart && isInternalDataPart(part)) { + return ""; + } + + if (typeof part === "string") { + return part; + } + + return ""; +} From 5fedaa9e60b546328376a5d62f339412161efcb6 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 14 Aug 2026 15:11:38 +0800 Subject: [PATCH 15/22] refactor(provider): extract settings, vision proxy, pricing + request headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the remaining request-path helpers out of extension.ts: - provider/settings.ts — modelConfigurationSchema / getSettings / modelLimits / modelCapabilities / shouldHideDeprecatedModel / resolveRawModelId / resolveVendorFromId / getConfiguredApiKey / isVisionProxyEnabled - provider/visionProxy.ts — proxyVision + showVisionProxyPicker (whole K partition) - models/pricing.ts — modelPricingFields + costCategory - request/headers.ts — buildOpenCodeRequestHeaders + stringifyInitiator etc. extension.ts is now down to ~1400 lines (from 4653). Behavior-preserving; compile + 291 tests + lint green. --- src/extension.ts | 752 +----------------------------------- src/models/pricing.ts | 88 +++++ src/provider/settings.ts | 199 ++++++++++ src/provider/visionProxy.ts | 322 +++++++++++++++ src/request/headers.ts | 110 ++++++ 5 files changed, 738 insertions(+), 733 deletions(-) create mode 100644 src/models/pricing.ts create mode 100644 src/provider/settings.ts create mode 100644 src/provider/visionProxy.ts create mode 100644 src/request/headers.ts diff --git a/src/extension.ts b/src/extension.ts index 699be3d..04af2c7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,12 +2,10 @@ import * as vscode from "vscode"; import { OpenCodeRequestError } from "./errors"; import { MODEL_METADATA_REVISION, - getContextSizeOptionsForModel, hasExplicitModelLimits, normalizeLiveModelMetadata, resolveModelMetadata, toEffectiveModelId, - VISION_CAPABLE_MODELS, type CachedModelMetadataSnapshot, type ModelMetadataFields, type ResolvedModelMetadata, @@ -22,21 +20,10 @@ import { streamResponsesApi as runStreamResponsesApi, type TransportRequestSummary, } from "./streaming"; -import { - GO_VENDOR, - ZEN_VENDOR, - AGENT_GO_VENDOR, - AGENT_ZEN_VENDOR, - resolveBaseVendor, - type AllProviderVendor, - type ProviderVendor, -} from "./providerTypes"; +import { GO_VENDOR, ZEN_VENDOR, AGENT_GO_VENDOR, AGENT_ZEN_VENDOR, resolveBaseVendor, type ProviderVendor } from "./providerTypes"; import { providerEnabledSetting } from "./providerEnablement"; import { registerInlineCompletions } from "./autocomplete"; -import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "./visionProxyCache"; import { providerModelDisplayName } from "./models/modelNames"; -import { buildStableModelCapabilities } from "./models/modelCapabilities"; -import { calculateModelLimits, type ModelLimits } from "./models/modelLimits"; import { buildAnthropicMessagesRequestBody, buildChatCompletionsRequestBody, @@ -53,8 +40,6 @@ import { AGENT_HOST_BYOK_MINOR_VERSION, CAPACITY_LIMITED_MODEL_NOTES, CONFIG_SECTION, - DEFAULT_REQUEST_TIMEOUT_SECONDS, - DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, DEFAULT_VISION_PROXY_PROMPT, EXTENSION_ID, KNOWN_UNAVAILABLE_MODEL_IDS, @@ -64,41 +49,24 @@ import { MODEL_LIST_FETCH_MAX_RETRIES, MODEL_LIST_FETCH_RETRY_BASE_MS, MODEL_LIST_FETCH_TIMEOUT_MS, - OPEN_CODE_CLIENT, RECENT_TRANSPORT_SUMMARY_LIMIT, RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX, secretKeyFor, SETTING_AGENTS_WINDOW, SETTING_AUTO_ENABLE_AGENTS_WINDOW, - SETTING_DEBUG_REASONING, SETTING_ENABLED, - SETTING_MAX_INPUT_TOKENS, - SETTING_MAX_TOKENS, - SETTING_REQUEST_TIMEOUT_SECONDS, SETTING_SHOW_PROVIDER_PREFIX, SETTING_SHOW_USAGE_STATUS_BAR, - SETTING_STREAM_IDLE_TIMEOUT_SECONDS, - SETTING_STRIP_THINK_TAGS, - SETTING_TEMPERATURE, - SETTING_THINKING_DEEPSEEK, - SETTING_THINKING_GLM, - SETTING_THINKING_KIMI, - SETTING_THINKING_MIMO, - SETTING_THINKING_MINIMAX, - SETTING_THINKING_OPENAI, - SETTING_THINKING_QWEN, - SETTING_THINKING_QWEN_BUDGET, SETTING_VISION_PROXY_WHOLE_CONVERSATION, SUPPORT_AGENTS_WINDOW_SETTING, SUPPORT_AGENTS_WINDOW_STATE_KEY, TEST_CONNECTION_TIMEOUT_MS, - THINKING_DEFAULTS, VISION_PROXY_MODEL_ID_KEY, VISION_PROXY_PROMPT_KEY, DEFAULT_USAGE_CHART_DAYS, SETTING_USAGE_CHART_DAYS, } from "./config"; -import { formatCount, formatTokenCount, formatUsd, getErrorMessage, isRecord, sleep, toFiniteNumber } from "./utils"; +import { formatCount, formatTokenCount, formatUsd, getErrorMessage, sleep } from "./utils"; import { isFreeModel } from "./models/metadata"; import { formatCacheHitRatio } from "./usage/usage"; @@ -152,7 +120,6 @@ import { clearOpenCodeModelMetadataCache, getModelMetadataSnapshot, getOpenCodeM import { ConfiguredLanguageModelInfoOptions, ConfiguredLanguageModelResponseOptions, - LanguageModelConfiguration, ModelListEntry, ModelListResponse, OpenCodeModel, @@ -161,8 +128,23 @@ import { getUserAgent, isTransientFetchError, } from "./provider/definitions"; -import { convertMessage, dataPartToBase64, normalizeMessages, trimOldImagesFromHistoryInPlace } from "./provider/messages"; -import { estimateChatMessageTokenCount, messageText } from "./provider/tokens"; +import { convertMessage, normalizeMessages, trimOldImagesFromHistoryInPlace } from "./provider/messages"; +import { estimateChatMessageTokenCount } from "./provider/tokens"; +import { + formatModalityBadges, + getConfiguredApiKey, + getRequestModelConfiguration, + getSettings, + isVisionProxyEnabled, + modelCapabilities, + modelConfigurationSchema, + modelLimits, + resolveRawModelId, + shouldHideDeprecatedModel, +} from "./provider/settings"; +import { proxyVision, showVisionProxyPicker } from "./provider/visionProxy"; +import { modelPricingFields } from "./models/pricing"; +import { buildOpenCodeRequestHeaders, stringifyInitiator } from "./request/headers"; /** * Hard upper limit (in bytes of raw image data) for a single image embedded @@ -1846,699 +1828,3 @@ class OpenCodeProvider implements vscode.LanguageModelChatProvider