From 7faabe1a9348767d46818388ec3d627e525696d3 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 22:57:51 +0500 Subject: [PATCH 01/36] fix(usage): define missing esc() HTML-escape helper in webview (Models tab crash) --- src/usage/dashboard.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/usage/dashboard.ts b/src/usage/dashboard.ts index 08c46da..85b3a21 100644 --- a/src/usage/dashboard.ts +++ b/src/usage/dashboard.ts @@ -699,6 +699,11 @@ function usageWebviewHtml(profileLabel: string): string { var ttip = document.getElementById('ttip'); var current = 'spend'; + // HTML-escape a value for innerHTML (model names come from gateway/CLI data). + function esc(s) { + return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + function el(tag, attrs, text) { var n = document.createElementNS(svgNS, tag); for (var k in attrs) n.setAttribute(k, attrs[k]); From 171adc924cd53146854c849791aaba6f923236b5 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 22:59:50 +0500 Subject: [PATCH 02/36] fix(request): guard tool-schema sanitizer against cyclic schemas (stack-overflow crash) --- src/request/schema.ts | 97 ++++++++++++++++++++++++----------------- src/test/schema.test.ts | 75 +++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 41 deletions(-) create mode 100644 src/test/schema.test.ts diff --git a/src/request/schema.ts b/src/request/schema.ts index 8006b0d..6ec5e76 100644 --- a/src/request/schema.ts +++ b/src/request/schema.ts @@ -12,7 +12,7 @@ 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()); + const sanitized = sanitizeJsonSchemaNode(root, root, new Set(), new WeakSet()); if (!isRecord(sanitized)) { return { type: "object", properties: {} }; } @@ -24,61 +24,76 @@ export function sanitizeToolSchema(schema: unknown): object { }; } -function sanitizeJsonSchemaNode(value: unknown, root: Record, seenRefs: Set): unknown { +function sanitizeJsonSchemaNode(value: unknown, root: Record, seenRefs: Set, visiting: WeakSet): unknown { if (Array.isArray(value)) { - return value.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs)); + return value.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs, visiting)); } 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); - } + // Cycle guard: a self/recursive (non-$ref) schema reference would recurse + // forever and crash the extension host with a stack overflow. Break the + // cycle by returning an empty schema for the back-edge. Mark-on-entry / + // unmark-on-exit keeps shared (DAG) sub-schemas intact while still catching + // true cycles. + if (visiting.has(value)) { + return {}; } - - const result: Record = {}; - for (const [key, child] of Object.entries(value)) { - if (key === "$schema" || key === "$id" || key === "$ref" || key === "$defs" || key === "definitions") { - continue; + visiting.add(value); + try { + 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, visiting); + return isRecord(resolved) + ? sanitizeJsonSchemaNode({ ...resolved, ...siblings }, root, nextSeenRefs, visiting) + : sanitizeJsonSchemaNode(siblings, root, nextSeenRefs, visiting); + } } - if (key === "properties" && isRecord(child)) { - result.properties = Object.fromEntries( - Object.entries(child).map(([propertyName, propertySchema]) => [ - propertyName, - sanitizeJsonSchemaNode(propertySchema, root, seenRefs), - ]), - ); - continue; - } + 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 === "items" || key === "additionalProperties") { - result[key] = sanitizeJsonSchemaNode(child, root, seenRefs); - continue; - } + if (key === "properties" && isRecord(child)) { + result.properties = Object.fromEntries( + Object.entries(child).map(([propertyName, propertySchema]) => [ + propertyName, + sanitizeJsonSchemaNode(propertySchema, root, seenRefs, visiting), + ]), + ); + continue; + } - if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(child)) { - result[key] = child.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs)); - continue; - } + if (key === "items" || key === "additionalProperties") { + result[key] = sanitizeJsonSchemaNode(child, root, seenRefs, visiting); + continue; + } + + if ((key === "anyOf" || key === "oneOf" || key === "allOf") && Array.isArray(child)) { + result[key] = child.map((item) => sanitizeJsonSchemaNode(item, root, seenRefs, visiting)); + continue; + } - if (["type", "description", "enum", "required", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"].includes(key)) { - result[key] = child; + if ( + ["type", "description", "enum", "required", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"].includes(key) + ) { + result[key] = child; + } } - } - return result; + return result; + } finally { + visiting.delete(value); + } } function resolveJsonPointer(root: Record, pointer: string): unknown { diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts new file mode 100644 index 0000000..a67a164 --- /dev/null +++ b/src/test/schema.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { sanitizeToolSchema } from "../request/schema.js"; + +describe("sanitizeToolSchema", () => { + it("flattens a plain object schema", () => { + const result = sanitizeToolSchema({ + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer", minimum: 1 }, + }, + required: ["name"], + }); + + assert.deepEqual(result, { + type: "object", + properties: { + name: { type: "string" }, + count: { type: "integer", minimum: 1 }, + }, + required: ["name"], + }); + }); + + it("drops $ref/$defs/$schema and resolves #/ pointers", () => { + const result = sanitizeToolSchema({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $defs: { coord: { type: "object", properties: { x: { type: "number" } } } }, + type: "object", + properties: { + pos: { $ref: "#/$defs/coord" }, + label: { type: "string", description: "a label" }, + }, + }); + + assert.deepEqual(result, { + type: "object", + properties: { + pos: { type: "object", properties: { x: { type: "number" } } }, + label: { type: "string", description: "a label" }, + }, + }); + }); + + it("does not recurse forever on a cyclic (non-$ref) schema", () => { + // A property that references the same schema object creates a cycle that + // used to blow the stack. It must terminate and emit an empty schema for + // the back-edge. + const node: Record = { + type: "object", + properties: {}, + }; + node.properties = { self: node }; + + const result = sanitizeToolSchema(node) as { properties: Record }; + + assert.deepEqual(result.properties.self, {}); + }); + + it("preserves a shared (DAG) sub-schema used by two properties", () => { + const shared = { type: "string", maxLength: 10 }; + const result = sanitizeToolSchema({ + type: "object", + properties: { a: shared, b: shared }, + }) as { properties: Record }; + + assert.deepEqual(result.properties.a, { type: "string", maxLength: 10 }); + assert.deepEqual(result.properties.b, { type: "string", maxLength: 10 }); + }); + + it("falls back to an empty object schema for non-object input", () => { + assert.deepEqual(sanitizeToolSchema(undefined), { type: "object", properties: {} }); + }); +}); From 42eeb56f2b901e00f8e5299d87c4b5293b35fe32 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:03:46 +0500 Subject: [PATCH 03/36] fix(streaming): cancel cleanly when the user aborts during 5xx backoff --- src/transports/engine.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/transports/engine.ts b/src/transports/engine.ts index 6941f20..73430ad 100644 --- a/src/transports/engine.ts +++ b/src/transports/engine.ts @@ -215,6 +215,17 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti consumedErrorBody = undefined; } + // A cancellation during the backoff wait means the user aborted while we + // were retrying a stale 5xx response. Fail cleanly as "cancelled" rather + // than surfacing the stale gateway error as if it were a fresh failure. + // (Read into a local so the throw does not narrow the token property for + // the rest of the function and trip no-unnecessary-condition.) + const cancelledDuringBackoff = options.token.isCancellationRequested; + if (cancelledDuringBackoff) { + abort("cancelled"); + throw new DOMException("Aborted", "AbortError"); + } + responseStatus = response.status; responseContentType = response.headers.get("content-type") ?? ""; options.output?.appendLine(`[http] ${String(response.status)} ${response.statusText} content-type=${responseContentType || ""}`); From 108c3463e105b2f20fb55315e53aede0b3ad0e13 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:04:29 +0500 Subject: [PATCH 04/36] fix(agents): revert auto-enabled core settings when autoEnableAgentsWindow is off --- src/extension.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index d5952d3..21d3a85 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -376,10 +376,11 @@ export function activate(context: vscode.ExtensionContext) { const autoEnabled = vscode.workspace.getConfiguration(CONFIG_SECTION).get(SETTING_AUTO_ENABLE_AGENTS_WINDOW, true); if (agentsWindowEnabled && autoEnabled) { void ensureAgentsWindowSupport(context); - } else if (!agentsWindowEnabled) { - // We may have enabled core settings for the Agents window; revert - // them when the user turns the feature off so the user's global - // configuration is restored. + } else { + // Revert the core settings we auto-enabled when either the feature + // is turned off OR auto-configuration is disabled — otherwise a + // user who only disables `autoEnableAgentsWindow` is left with the + // extension's settings permanently flipped in their global config. void revertAgentsWindowSupport(context); } } From c4b524625cd517115173f59f022dd3e96bc5aa46 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:05:40 +0500 Subject: [PATCH 05/36] fix(usage): count reasoning marker as internal data (no phantom tokens in estimates) --- src/chatParts.ts | 6 +++++- src/provider/messages.ts | 24 +++++++++++++----------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/src/chatParts.ts b/src/chatParts.ts index 7339e95..2065485 100644 --- a/src/chatParts.ts +++ b/src/chatParts.ts @@ -23,7 +23,11 @@ export function createUsageDataParts(usage: UsageSnapshot): vscode.LanguageModel } export function isInternalDataPart(part: vscode.LanguageModelDataPart): boolean { - return part.mimeType === OPENCODE_USAGE_DATA_MIME || part.mimeType === COPILOT_USAGE_DATA_MIME; + return ( + part.mimeType === OPENCODE_USAGE_DATA_MIME || + part.mimeType === COPILOT_USAGE_DATA_MIME || + part.mimeType === OPENCODE_REASONING_DATA_MIME + ); } /** diff --git a/src/provider/messages.ts b/src/provider/messages.ts index aae75bf..702eac7 100644 --- a/src/provider/messages.ts +++ b/src/provider/messages.ts @@ -157,6 +157,19 @@ export async function convertMessage( 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. Must be checked BEFORE + // isInternalDataPart (which now includes the marker MIME) so the marker + // is processed rather than skipped as an internal usage part. + const reasoning = readReasoningMarker(part); + if (reasoning) { + thinkingTextParts.push(reasoning); + } + continue; + } + if (part instanceof vscode.LanguageModelDataPart && isInternalDataPart(part)) { continue; } @@ -169,17 +182,6 @@ export async function convertMessage( 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); From 12a06ac498fbc2aef3b6d3e996fdae3b62bb89f1 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:09:52 +0500 Subject: [PATCH 06/36] fix(utils): correct negative USD sign placement and escape single quotes --- src/test/utils.test.ts | 10 ++++++++++ src/utils.ts | 12 +++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/test/utils.test.ts b/src/test/utils.test.ts index 44eb833..3dae851 100644 --- a/src/test/utils.test.ts +++ b/src/test/utils.test.ts @@ -107,6 +107,12 @@ describe("utils — formatUsd", () => { assert.equal(formatUsd(1_500), "$1.50K"); assert.equal(formatUsd(1_234_567), "$1.23M"); }); + + it("places the sign before the currency symbol", () => { + assert.equal(formatUsd(-5), "-$5.00"); + assert.equal(formatUsd(-1_500), "-$1.50K"); + assert.equal(formatUsd(-0.005), "-$0.0050"); + }); }); describe("utils — formatTokenCount", () => { @@ -154,6 +160,10 @@ describe("utils — escapeHtml", () => { assert.equal(escapeHtml(``), "<a href="x&y">"); assert.equal(escapeHtml("plain"), "plain"); }); + + it("escapes single quotes for single-quoted attribute contexts", () => { + assert.equal(escapeHtml("it's a 'test'"), "it's a 'test'"); + }); }); describe("utils — sleep / sleepWithCancellation", () => { diff --git a/src/utils.ts b/src/utils.ts index d626233..1a38325 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -65,10 +65,12 @@ export function parseJsonSafe(text: string): unknown { */ export function formatUsd(value: number): string { const abs = Math.abs(value); - if (abs >= 1_000_000) return `$${(value / 1_000_000).toFixed(2)}M`; - if (abs >= 1_000) return `$${(value / 1_000).toFixed(2)}K`; - if (abs >= 0.01 || value === 0) return `$${value.toFixed(2)}`; - return `$${value.toFixed(4)}`; + // Render the sign before the currency symbol (`-$5.00`, not `$-5.00`). + const sign = value < 0 ? "-" : ""; + if (abs >= 1_000_000) return `${sign}$${(abs / 1_000_000).toFixed(2)}M`; + if (abs >= 1_000) return `${sign}$${(abs / 1_000).toFixed(2)}K`; + if (abs >= 0.01 || value === 0) return `${sign}$${abs.toFixed(2)}`; + return `${sign}$${abs.toFixed(4)}`; } /** @@ -116,7 +118,7 @@ export function formatRelativeTime(target: Date, from: Date = new Date()): strin /** Escape a value for embedding in HTML/SVG text content. */ export function escapeHtml(value: string): string { - return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + return value.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } // ─── Async helpers ─────────────────────────────────────────────────────────── From acae699c299f618294307bcaa460c6c8c2b39111 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:11:11 +0500 Subject: [PATCH 07/36] fix(usage): bucket mid-day events into the correct day (floor instead of round) --- src/test/goUsageTracker.test.ts | 21 +++++++++++++++++++++ src/usage/history.ts | 5 ++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 7337f08..7148bee 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -876,4 +876,25 @@ describe("buildUsageSeries", () => { assert.equal(series.days[0].requests, 2); assert.equal(series.days[1].requests, 2); }); + + it("keeps a mid-day event in its own day bucket (floor, not round)", () => { + const midDay: HistoryRow[] = [ + { + createdMs: dayMs - DAY + DAY * 0.6, // afternoon of the previous day + cost: 0.1, + tokensInput: 10, + tokensOutput: 10, + tokensReasoning: 0, + tokensCacheRead: 0, + tokensTotal: 20, + cwd: "/repo", + modelId: "qwen3.6-plus", + }, + ]; + const series = buildUsageSeries(midDay, [], 2, dayMs, "cli"); + // Window: dayMs-1*DAY .. dayMs → the afternoon event belongs to yesterday. + assert.equal(series.days[0].dayStart, dayMs - DAY); + assert.equal(series.days[0].requests, 1); + assert.equal(series.days[1].requests, 0); + }); }); diff --git a/src/usage/history.ts b/src/usage/history.ts index ef617cd..b921d93 100644 --- a/src/usage/history.ts +++ b/src/usage/history.ts @@ -164,7 +164,10 @@ export function buildUsageSeries( const byModel = new Map>(); const add = (model: string | undefined, timestamp: number, cost: number, tokens: number): void => { - const index = Math.round((timestamp - firstDay) / DAY_MS); + // Bucket by the day whose [start, start+DAY) range contains the event. + // floor (not round) keeps mid-day events in the correct day — round could + // push an afternoon event into the next day's bucket. + const index = Math.floor((timestamp - firstDay) / DAY_MS); if (index < 0 || index >= bucketCount) return; const day = buckets[index]; day.cost += cost; From 3a53a866dc40209a854fdec78a8f0bdd18acaf62 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:11:46 +0500 Subject: [PATCH 08/36] fix(usage): clamp subscription percent to [0, 100] (no negative percentages) --- src/usage/tracker.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/usage/tracker.ts b/src/usage/tracker.ts index abe76fe..428bd34 100644 --- a/src/usage/tracker.ts +++ b/src/usage/tracker.ts @@ -424,7 +424,9 @@ export class GoUsageTracker { getSummary(): UsageSummary { const nowMs = Date.now(); - const clamp = (v: number, limit: number) => Math.round(Math.min(100, (v / limit) * 100) * 10) / 10; + // Percent is bounded to [0, 100] — a negative spend (baseline over- + // correction) must never render a negative percentage. + const clamp = (v: number, limit: number) => Math.round(Math.min(100, Math.max(0, (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 From 76ef45a2a5cad4cb42e8a920f759ea73f4f4e717 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:12:24 +0500 Subject: [PATCH 09/36] fix(usage): clear per-profile serverUsage and everTracked state on profile delete --- src/extension.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/extension.ts b/src/extension.ts index 21d3a85..781bcce 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,6 +7,8 @@ import { configureUtilityModels, toggleProviderEnabled } from "./commands/provid import { CONFIG_SECTION, DEFAULT_USAGE_CHART_DAYS, + GO_EVER_TRACKED_KEY, + GO_SERVER_USAGE_KEY, SETTING_AGENTS_WINDOW, SETTING_AUTO_ENABLE_AGENTS_WINDOW, SETTING_SHOW_PROVIDER_PREFIX, @@ -316,6 +318,10 @@ export function activate(context: vscode.ExtensionContext) { ctx.globalState.update(`opencodego.usageLog.v1.${fp}`, []); ctx.globalState.update(`opencodego.usageBaseline.v1.${fp}`, {}); ctx.globalState.update(`opencodego.sessionCosts.v1.${fp}`, []); + // Also clear the per-profile server snapshot + ever-tracked flags so a + // re-added profile (same key) doesn't resurrect stale meters/state. + ctx.globalState.update(`${GO_SERVER_USAGE_KEY}.${fp}`, undefined); + ctx.globalState.update(`${GO_EVER_TRACKED_KEY}.${fp}`, undefined); const remaining = readProfiles(ctx).filter((p) => p.fingerprint !== fp); await writeProfiles(ctx, remaining); From 21141860c46191e03f8a401cb71414c8956b2485 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:15:44 +0500 Subject: [PATCH 10/36] fix(streaming): remove dead delta.message reasoning extraction --- src/transports/extract.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/transports/extract.ts b/src/transports/extract.ts index 69bfe85..3a3acc0 100644 --- a/src/transports/extract.ts +++ b/src/transports/extract.ts @@ -72,12 +72,9 @@ export function extractTextFromDelta(delta: Record): string { /** 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, - ]; + // Callers pass either a `choices[0].delta` or a `choices[0].message` object; + // neither carries a nested `.message`, so only the top-level fields are read. + const candidates: unknown[] = [delta.reasoning_content, delta.reasoning, delta.thinking]; let collected = ""; for (const candidate of candidates) { if (typeof candidate === "string") { From 15643f563c56c9f33e82011d0f803336a0df4a1a Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:15:57 +0500 Subject: [PATCH 11/36] fix(provider): clamp temperature to the provider-accepted range --- src/provider/settings.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/provider/settings.ts b/src/provider/settings.ts index a8fdb34..2b65e82 100644 --- a/src/provider/settings.ts +++ b/src/provider/settings.ts @@ -90,7 +90,9 @@ export function getSettings(): ApiSettings { // Config values are sanitized so a misconfigured (e.g. string) value never // reaches the request body and 400s upstream. return { - temperature: toFiniteNumber(config.get(SETTING_TEMPERATURE, 0.2), 0.2), + // Clamp to the range providers accept ([0, 2]) so a bad config value never + // 400s upstream before the retry layer can strip it. + temperature: toFiniteNumber(config.get(SETTING_TEMPERATURE, 0.2), 0.2, 0, 2), maxOutputTokensOverride: toFiniteNumber(config.get(SETTING_MAX_TOKENS, 0), 0, 0), maxInputTokensOverride: toFiniteNumber(config.get(SETTING_MAX_INPUT_TOKENS, 0), 0, 0), debugReasoning: config.get(SETTING_DEBUG_REASONING, false), From df6206eb95f0b277bff7c124fe4a95569627f9cf Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:16:09 +0500 Subject: [PATCH 12/36] fix(models): sync Go model lists (drop dead ring entry, add minimax-m3) --- src/models/metadata.ts | 1 - src/provider/definitions.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/metadata.ts b/src/models/metadata.ts index 75c57fa..463b23a 100644 --- a/src/models/metadata.ts +++ b/src/models/metadata.ts @@ -184,7 +184,6 @@ const MODEL_LIMITS_BY_PROVIDER: Record "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", From 1b355afdeebdd012741396e810eb7af296495201 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:16:52 +0500 Subject: [PATCH 13/36] fix(usage): raise sqlite3 history read cap so large histories don't silently fail --- src/usage/history.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/usage/history.ts b/src/usage/history.ts index b921d93..ae6410d 100644 --- a/src/usage/history.ts +++ b/src/usage/history.ts @@ -349,7 +349,9 @@ function readHistoryViaSqliteCli(): HistoryRow[] | null { try { const result = execFileSync(binary, ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { timeout: 10_000, - maxBuffer: 64 * 1024 * 1024, + // 256MB: a power user's full CLI history JSON can exceed 64MB; a too- + // small cap silently makes the usage panel show no history at all. + maxBuffer: 256 * 1024 * 1024, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); From eac86c906f0cdcdab2dbe492c857a4c82717efdf Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:21:45 +0500 Subject: [PATCH 14/36] fix(streaming): read 5xx body so Router.Unavailable retry branch actually fires --- src/transports/engine.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/transports/engine.ts b/src/transports/engine.ts index 73430ad..bcf7195 100644 --- a/src/transports/engine.ts +++ b/src/transports/engine.ts @@ -198,7 +198,18 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti // 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 ?? "")) { + while (attempt < TRANSIENT_5XX_MAX_RETRIES) { + // Consume a 5xx body so body-named transient conditions (Router. + // Unavailable) are recognized by isTransientServerError, and the same + // body is reused for the error message if the retries are exhausted. + // (502/503/504 are retried by status alone, but reading the small error + // body once here also covers the body-scanned 5xx cases.) + if (response.status >= 500 && consumedErrorBody === undefined) { + consumedErrorBody = await response.text(); + } + if (!isTransientServerError(response.status, consumedErrorBody ?? "")) { + break; + } attempt += 1; // Jitter spreads concurrent retries so they don't pile on the gateway // at the same timestamp. @@ -211,7 +222,7 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti break; } response = await fetchWithBody(payload); - // A fresh response may carry a new error body; drop stale 400 detail. + // A fresh response may carry a new error body; drop stale detail. consumedErrorBody = undefined; } From 84ec5925f1b33e7998ccf212c35367d4d366d33c Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:22:46 +0500 Subject: [PATCH 15/36] fix(autocomplete): don't let a stale token cancel the newer pending debounce --- src/autocomplete/provider.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/autocomplete/provider.ts b/src/autocomplete/provider.ts index 1119408..31e5bc8 100644 --- a/src/autocomplete/provider.ts +++ b/src/autocomplete/provider.ts @@ -89,7 +89,12 @@ export class OpenCodeInlineCompletionProvider implements vscode.InlineCompletion }; const tokenSubscription = token.onCancellationRequested(() => { - this.debouncer.cancel(); + // Do NOT cancel the shared debouncer here: VS Code may cancel this + // request's token AFTER a newer keystroke already scheduled its own + // debounced run, and aborting the debouncer would kill that newer + // pending suggestion. The debouncer cancels the previous run itself + // when the next debounce() is scheduled; here we only resolve this + // request's promise as "no suggestion". finish(undefined); }); From 2df9c4cd89d46847bae0fc9bfbad610eec782efe Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:23:58 +0500 Subject: [PATCH 16/36] fix(vision): keep a placeholder on empty vision descriptions; reuse cache in whole-conversation mode --- src/provider/visionProxy.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/provider/visionProxy.ts b/src/provider/visionProxy.ts index 0fa7bdd..97d5f66 100644 --- a/src/provider/visionProxy.ts +++ b/src/provider/visionProxy.ts @@ -7,6 +7,9 @@ import { dataPartToBase64 } from "./messages"; import { resolveRawModelId, resolveVendorFromId } from "./settings"; import { imageDescriptionKey, lookupImageDescriptions, storeImageDescriptions } from "../visionProxyCache"; +/** Placeholder used when the vision model returns no text for an image. */ +const VISION_DESCRIPTION_UNAVAILABLE = "[Image could not be described by the vision model]"; + /** Result of a vision-proxy pass: per-message descriptions plus cache stats. */ export interface VisionProxyResult { /** Original message index → text description (only for messages with images). */ @@ -148,6 +151,18 @@ export async function proxyVision( imageIndices.push(index); allHashes.push(...imageParts.map((part) => imageDescriptionKey(dataPartToBase64(part.data)))); } + + // If every image in the conversation is already described, reuse the + // cached combined description instead of re-calling the vision model. + const cachedCombined = lookupImageDescriptions(allHashes); + if (imageIndices.length > 0 && cachedCombined !== undefined) { + cacheHits++; + for (const index of imageIndices) { + descriptions.set(index, cachedCombined); + } + return { descriptions, cacheHits, cacheMisses }; + } + if (imageIndices.length > 0) { cacheMisses++; const model = await resolveVisionModel(); @@ -161,6 +176,10 @@ export async function proxyVision( for (const index of imageIndices) { descriptions.set(index, fullDescription); } + } else { + for (const index of imageIndices) { + descriptions.set(index, VISION_DESCRIPTION_UNAVAILABLE); + } } } return { descriptions, cacheHits, cacheMisses }; @@ -193,6 +212,10 @@ export async function proxyVision( fullDescription += part; } if (!fullDescription) { + // The vision model returned nothing — keep the message present with a + // neutral placeholder instead of leaving it undescribed (which would + // make the caller strip the image with a misleading "unavailable" note). + descriptions.set(index, VISION_DESCRIPTION_UNAVAILABLE); continue; } storeImageDescriptions(hashes, fullDescription); From da881c9d810c3073a776810b42e8c396e38e6666 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:24:43 +0500 Subject: [PATCH 17/36] fix(streaming): don't drop reasoning when there is no progress sink --- src/transports/extractors.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/transports/extractors.ts b/src/transports/extractors.ts index 11df68c..1b2a113 100644 --- a/src/transports/extractors.ts +++ b/src/transports/extractors.ts @@ -90,11 +90,13 @@ abstract class BaseResponseExtractor { 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) { + // If the thinking part API is available AND we had a progress sink, + // reasoning was already streamed live during extractStreamParts via + // handleReasoning(). The accumulated reasoningContent is retained only + // for tool-call replication (flushToolCalls → onReasoningContent). + // Without a progress sink nothing was ever streamed, so fall through to + // the legacy emit path rather than silently dropping the reasoning. + if (thinkingPartConstructor && this.progress) { this.reasoningContent = ""; return; } From 54fb5c4915ad614bfe7681d709f231b6657e2123 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:25:25 +0500 Subject: [PATCH 18/36] fix(routing): emit stable synthetic ids for Google tool calls --- src/core/routing.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/routing.ts b/src/core/routing.ts index 3fe45f0..4750853 100644 --- a/src/core/routing.ts +++ b/src/core/routing.ts @@ -224,7 +224,10 @@ export function normalizeGoogleStreamEvent(data: unknown): unknown { return [ { index, - id: "", + // Gemini has no native tool-call ids; emit a stable synthetic one so + // downstream tool-call parts carry a real callId (empty ids made calls + // indistinguishable and broke reasoning replication). + id: `google-tool-${String(index)}`, type: "function", function: { name: part.functionCall.name, @@ -273,14 +276,14 @@ export function normalizeGoogleFullResponse(data: unknown): unknown { .filter((part) => typeof part.text === "string" && part.thought === true) .map((part) => part.text as string) .join(""); - const toolCalls = parts.flatMap((part) => { + const toolCalls = parts.flatMap((part, index) => { if (!isRecord(part.functionCall) || typeof part.functionCall.name !== "string") { return []; } return [ { - id: "", + id: `google-tool-${String(index)}`, type: "function", function: { name: part.functionCall.name, From 05f5711cdb63dc35e1ce6c65f78a07938d0d0497 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:26:13 +0500 Subject: [PATCH 19/36] fix(provider): keep reasoning_content when merging consecutive assistant messages --- src/provider/messages.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/provider/messages.ts b/src/provider/messages.ts index 702eac7..10b8413 100644 --- a/src/provider/messages.ts +++ b/src/provider/messages.ts @@ -309,6 +309,12 @@ export function normalizeMessages(messages: ApiMessage[]): ApiMessage[] { !prevHasToolCalls && !msgHasToolCalls ) { + // Merging two assistant messages must not drop the second one's + // reasoning_content — DeepSeek-style models require it echoed back on + // the next turn. Concatenate both into the merged message. + if (message.reasoning_content) { + previous.reasoning_content = [previous.reasoning_content, message.reasoning_content].filter(Boolean).join("\n"); + } previous.content = `${prevContent}\n\n${msgContent}`.trim(); } else { normalized.push({ ...message }); From e147107cbd3ee31e713720d90aea9583d2e7b3db Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:26:50 +0500 Subject: [PATCH 20/36] fix(usage): clamp progress bar to [0,100] --- src/usage/formatting.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/usage/formatting.ts b/src/usage/formatting.ts index 2bcf302..aa70a59 100644 --- a/src/usage/formatting.ts +++ b/src/usage/formatting.ts @@ -5,7 +5,10 @@ import type { PeriodUsage, UsageSummary } from "./tracker"; // ─── Formatting helpers ────────────────────────────────────────────────────── function progressBar(percent: number, width = 10): string { - const filled = Math.round((percent / 100) * width); + // Clamp so out-of-range values (negative spend, >100% overage) never render + // a bar wider than `width` or with a negative fill. + const clamped = Math.max(0, Math.min(100, percent)); + const filled = Math.round((clamped / 100) * width); return "█".repeat(filled) + "░".repeat(width - filled); } From 27f368cfc719c93bffc78d4fb8619578d821d63b Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:27:03 +0500 Subject: [PATCH 21/36] fix(streaming): send Accept header on chat POST requests --- src/transports/engine.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/transports/engine.ts b/src/transports/engine.ts index bcf7195..9a45d19 100644 --- a/src/transports/engine.ts +++ b/src/transports/engine.ts @@ -153,6 +153,7 @@ export async function streamOpenCodeResponse(options: StreamOpenCodeResponseOpti const fetchHeaders: Record = { ...(options.authHeaders ?? { Authorization: `Bearer ${options.apiKey}` }), "Content-Type": "application/json", + Accept: "application/json", ...options.requestHeaders, }; const fetchWithBody = (body: string) => From f7a6cb40be3633a0d2e7988b0f09130d5e044a74 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:28:46 +0500 Subject: [PATCH 22/36] fix(request): preserve top-level tool-schema enums and common keywords --- src/request/schema.ts | 20 +++++++++++++++++++- src/test/schema.test.ts | 18 ++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/request/schema.ts b/src/request/schema.ts index 6ec5e76..9a9f2fa 100644 --- a/src/request/schema.ts +++ b/src/request/schema.ts @@ -21,6 +21,9 @@ export function sanitizeToolSchema(schema: unknown): object { type: "object", properties: isRecord(sanitized.properties) ? sanitized.properties : {}, ...(Array.isArray(sanitized.required) ? { required: sanitized.required } : {}), + // A top-level enum (e.g. a tool input that is a fixed set of values) was + // previously flattened away — keep it so the provider still validates it. + ...(Array.isArray(sanitized.enum) ? { enum: sanitized.enum } : {}), }; } @@ -84,7 +87,22 @@ function sanitizeJsonSchemaNode(value: unknown, root: Record, s } if ( - ["type", "description", "enum", "required", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"].includes(key) + [ + "type", + "description", + "enum", + "const", + "pattern", + "format", + "default", + "required", + "minimum", + "maximum", + "minLength", + "maxLength", + "minItems", + "maxItems", + ].includes(key) ) { result[key] = child; } diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index a67a164..7844dd7 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -72,4 +72,22 @@ describe("sanitizeToolSchema", () => { it("falls back to an empty object schema for non-object input", () => { assert.deepEqual(sanitizeToolSchema(undefined), { type: "object", properties: {} }); }); + + it("preserves a top-level enum instead of flattening it away", () => { + const result = sanitizeToolSchema({ enum: ["fast", "balanced", "thorough"] }); + assert.deepEqual(result, { type: "object", properties: {}, enum: ["fast", "balanced", "thorough"] }); + }); + + it("keeps pattern/format/default keywords on properties", () => { + const result = sanitizeToolSchema({ + type: "object", + properties: { + code: { type: "string", pattern: "^[a-z]+$", description: "a code" }, + mode: { type: "string", enum: ["on", "off"], default: "off" }, + }, + }) as { properties: Record }; + + assert.deepEqual(result.properties.code, { type: "string", pattern: "^[a-z]+$", description: "a code" }); + assert.deepEqual(result.properties.mode, { type: "string", enum: ["on", "off"], default: "off" }); + }); }); From a1782c07f8381c0249e7fa02964e3d6f4aa1ff6b Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:29:29 +0500 Subject: [PATCH 23/36] fix(streaming): don't emit duplicate text when both message and choice carry it --- src/transports/extract.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/transports/extract.ts b/src/transports/extract.ts index 3a3acc0..fcd3157 100644 --- a/src/transports/extract.ts +++ b/src/transports/extract.ts @@ -16,9 +16,11 @@ function extractChatCompletionParts(data: unknown): vscode.LanguageModelResponse const parts: vscode.LanguageModelResponsePart[] = []; const message = first.message; + let emittedText = false; if (isRecord(message)) { const text = extractTextFromDelta(message); if (text) { + emittedText = true; parts.push(new vscode.LanguageModelTextPart(text)); } else { const reasoning = extractReasoningFromDelta(message); @@ -38,7 +40,10 @@ function extractChatCompletionParts(data: unknown): vscode.LanguageModelResponse } } - if (typeof first.text === "string") { + // Some gateways put text in both `message.content` and `choices[0].text`; + // emitting both would duplicate the response, so only fall back to the + // choice-level field when the message produced no text. + if (typeof first.text === "string" && !emittedText) { parts.push(new vscode.LanguageModelTextPart(first.text)); } From f39ffb0f7dd5277a6e1cda777a0dca7d1c9123c5 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:30:50 +0500 Subject: [PATCH 24/36] fix(thinking): expose plain 'on' option for toggle+effort models --- src/thinking/schema.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/thinking/schema.ts b/src/thinking/schema.ts index a87780d..59a2093 100644 --- a/src/thinking/schema.ts +++ b/src/thinking/schema.ts @@ -53,8 +53,10 @@ export function schemaFromReasoningOptions(metadata?: ResolvedModelMetadata): Th 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) { + // A toggle-capable model should always expose a plain "on" choice, even when + // it also has effort levels (previously "on" was only added for toggle-only + // models, so a user wanting plain enablement couldn't pick it). + if (hasToggle && !enumOptions.includes("on")) { enumOptions.push("on"); enumLabels.push("On"); enumDescriptions.push("Enable reasoning"); From be3743b68e23043ffaff68d9f74caf68b72402e4 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:35:14 +0500 Subject: [PATCH 25/36] fix(provider): validate thinking setting values and cap timeout settings --- src/provider/settings.ts | 74 ++++++++++++++++++++++++++++++++++------ 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/src/provider/settings.ts b/src/provider/settings.ts index 2b65e82..45f89f5 100644 --- a/src/provider/settings.ts +++ b/src/provider/settings.ts @@ -26,11 +26,28 @@ import { buildStableModelCapabilities } from "../models/modelCapabilities"; import { calculateModelLimits, type ModelLimits } from "../models/modelLimits"; import { AGENT_GO_VENDOR, AGENT_ZEN_VENDOR, GO_VENDOR, ZEN_VENDOR, resolveBaseVendor, type AllProviderVendor } from "../providerTypes"; import type { ApiSettings } from "../request/types"; -import { thinkingProviderFor, type ThinkingSettings } from "../thinking"; +import { thinkingProviderFor } from "../thinking"; import { extensionContext } from "../usage/dashboard"; import { toFiniteNumber } from "../utils"; import type { LanguageModelConfiguration, ProviderDefinition } from "./definitions"; +/** Allowed values per thinking setting — a misconfigured value must never reach the wire. */ +const THINKING_ALLOWED_VALUES = { + 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"], +} as const; + +/** Return `value` when it is one of `allowed`, else `fallback`. */ +function validThinkingValue(value: unknown, allowed: readonly T[], fallback: T): T { + return typeof value === "string" && (allowed as readonly string[]).includes(value) ? (value as T) : fallback; +} + export function getConfiguredApiKey(options?: { configuration?: LanguageModelConfiguration }): string | undefined { const configuredApiKey = options?.configuration?.apiKey; return typeof configuredApiKey === "string" && configuredApiKey.trim() ? configuredApiKey.trim() : undefined; @@ -96,24 +113,59 @@ export function getSettings(): ApiSettings { maxOutputTokensOverride: toFiniteNumber(config.get(SETTING_MAX_TOKENS, 0), 0, 0), maxInputTokensOverride: toFiniteNumber(config.get(SETTING_MAX_INPUT_TOKENS, 0), 0, 0), debugReasoning: config.get(SETTING_DEBUG_REASONING, false), + // Clamped to sane upper bounds so a misconfigured huge value can't + // silently disable the timeout safety net. requestTimeoutMs: - toFiniteNumber(config.get(SETTING_REQUEST_TIMEOUT_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS), DEFAULT_REQUEST_TIMEOUT_SECONDS, 1) * - 1000, + toFiniteNumber( + config.get(SETTING_REQUEST_TIMEOUT_SECONDS, DEFAULT_REQUEST_TIMEOUT_SECONDS), + DEFAULT_REQUEST_TIMEOUT_SECONDS, + 1, + 1800, + ) * 1000, streamIdleTimeoutMs: toFiniteNumber( config.get(SETTING_STREAM_IDLE_TIMEOUT_SECONDS, DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS), DEFAULT_STREAM_IDLE_TIMEOUT_SECONDS, 1, + 600, ) * 1000, thinking: { - deepseek: config.get(SETTING_THINKING_DEEPSEEK, THINKING_DEFAULTS.deepseek), - glm: config.get(SETTING_THINKING_GLM, THINKING_DEFAULTS.glm), - kimi: config.get(SETTING_THINKING_KIMI, THINKING_DEFAULTS.kimi), - minimax: config.get(SETTING_THINKING_MINIMAX, THINKING_DEFAULTS.minimax), - openai: config.get(SETTING_THINKING_OPENAI, THINKING_DEFAULTS.openai), - qwen: config.get(SETTING_THINKING_QWEN, THINKING_DEFAULTS.qwen), - qwenBudget: config.get(SETTING_THINKING_QWEN_BUDGET, THINKING_DEFAULTS.qwenBudget), - mimo: config.get(SETTING_THINKING_MIMO, THINKING_DEFAULTS.mimo), + deepseek: validThinkingValue( + config.get(SETTING_THINKING_DEEPSEEK, THINKING_DEFAULTS.deepseek), + THINKING_ALLOWED_VALUES.deepseek, + THINKING_DEFAULTS.deepseek, + ), + glm: validThinkingValue(config.get(SETTING_THINKING_GLM, THINKING_DEFAULTS.glm), THINKING_ALLOWED_VALUES.glm, THINKING_DEFAULTS.glm), + kimi: validThinkingValue( + config.get(SETTING_THINKING_KIMI, THINKING_DEFAULTS.kimi), + THINKING_ALLOWED_VALUES.kimi, + THINKING_DEFAULTS.kimi, + ), + minimax: validThinkingValue( + config.get(SETTING_THINKING_MINIMAX, THINKING_DEFAULTS.minimax), + THINKING_ALLOWED_VALUES.minimax, + THINKING_DEFAULTS.minimax, + ), + openai: validThinkingValue( + config.get(SETTING_THINKING_OPENAI, THINKING_DEFAULTS.openai), + THINKING_ALLOWED_VALUES.openai, + THINKING_DEFAULTS.openai, + ), + qwen: validThinkingValue( + config.get(SETTING_THINKING_QWEN, THINKING_DEFAULTS.qwen), + THINKING_ALLOWED_VALUES.qwen, + THINKING_DEFAULTS.qwen, + ), + qwenBudget: validThinkingValue( + config.get(SETTING_THINKING_QWEN_BUDGET, THINKING_DEFAULTS.qwenBudget), + THINKING_ALLOWED_VALUES.qwenBudget, + THINKING_DEFAULTS.qwenBudget, + ), + mimo: validThinkingValue( + config.get(SETTING_THINKING_MIMO, THINKING_DEFAULTS.mimo), + THINKING_ALLOWED_VALUES.mimo, + THINKING_DEFAULTS.mimo, + ), }, stripThinkTags: config.get(SETTING_STRIP_THINK_TAGS, "auto"), }; From 1072290bf6f03164f5fe541dbf23a0809827ca8e Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:36:14 +0500 Subject: [PATCH 26/36] fix(streaming): report reasoning marker through the context-window progress wrapper --- src/transports/chatCompletions.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/transports/chatCompletions.ts b/src/transports/chatCompletions.ts index 5ba6aba..64afce1 100644 --- a/src/transports/chatCompletions.ts +++ b/src/transports/chatCompletions.ts @@ -4,6 +4,7 @@ import { createThinkTagFilter } from "./thinkTags"; import { createReasoningDebugger, streamOpenCodeResponse } from "./engine"; import { OpenAiResponseExtractor } from "./extractors"; import { extractChatCompletionParts } from "./extract"; +import { reportProgressPart } from "./streamParts"; /** OpenAI-compatible chat-completions transport. */ export async function streamChatCompletions(options: StreamRequestOptions): Promise { @@ -41,10 +42,11 @@ export async function streamChatCompletions(options: StreamRequestOptions): Prom 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. + // is a no-op today — kept as the designed seam. Reported through the shared + // progress wrapper so a bound context-window request stays correctly scoped. const reasoningMarker = extractor.flushReasoningMarker(); if (reasoningMarker) { - options.progress.report(reasoningMarker); + reportProgressPart(options.requestHeaders["x-opencode-request"], options.progress, reasoningMarker); } options.output?.appendLine( `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, From 1df4a2c9a9c76efcf8c8bcade73d4bd5228f3812 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:37:02 +0500 Subject: [PATCH 27/36] fix(streaming): add empty-response diagnostics to Anthropic and Google transports --- src/transports/anthropic.ts | 5 +++++ src/transports/google.ts | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/transports/anthropic.ts b/src/transports/anthropic.ts index 82d5863..2318286 100644 --- a/src/transports/anthropic.ts +++ b/src/transports/anthropic.ts @@ -25,4 +25,9 @@ export async function streamAnthropicMessages(options: StreamRequestOptions): Pr options.output?.appendLine( `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, ); + 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.`, + ); + } } diff --git a/src/transports/google.ts b/src/transports/google.ts index a996783..14c2ab5 100644 --- a/src/transports/google.ts +++ b/src/transports/google.ts @@ -28,4 +28,9 @@ export async function streamGoogleGenerateContent(options: StreamRequestOptions) options.output?.appendLine( `[stream-summary model=${options.modelId}] textChars=${String(extractor.emittedText)} toolCalls=${String(extractor.emittedTools)} reasoningChars=${String(extractor.reasoningChars)}`, ); + 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.`, + ); + } } From b3ad8e188b3186fa6a981c929bf07fc02595f9f8 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:37:44 +0500 Subject: [PATCH 28/36] fix(provider): log request completion for the Google transport too --- src/provider/OpenCodeProvider.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/provider/OpenCodeProvider.ts b/src/provider/OpenCodeProvider.ts index 04cd2cd..5989ba7 100644 --- a/src/provider/OpenCodeProvider.ts +++ b/src/provider/OpenCodeProvider.ts @@ -971,6 +971,7 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider Date: Fri, 14 Aug 2026 23:39:05 +0500 Subject: [PATCH 29/36] fix(tooling): lint staged renames and resolve .js specifiers to .ts sources --- scripts/staged-lint.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/staged-lint.ts b/scripts/staged-lint.ts index 770146e..465d49e 100644 --- a/scripts/staged-lint.ts +++ b/scripts/staged-lint.ts @@ -49,9 +49,11 @@ function run(cmd: string, args: string[]): CommandResult { return { status: res.status, output: `${res.stdout}${res.stderr}`.trim() }; } -/** Staged (added/copied/modified) file paths relative to the repo root. */ +/** Staged (added/copied/modified/renamed) file paths relative to the repo root. */ function stagedFiles(): string[] { - const res = run("git", ["diff", "--cached", "--name-only", "-z", "--diff-filter=ACM"]); + // Include renamed (R) files and enable rename detection so a staged rename's + // new path is linted too. + const res = run("git", ["diff", "--cached", "--name-only", "-z", "--find-renames", "--diff-filter=ACMR"]); if (res.status !== 0) { return []; } @@ -88,6 +90,13 @@ function resolveImport(fromFile: string, spec: string): string | undefined { path.join(base, "index.ts"), path.join(base, "index.js"), ]; + // NodeNext-style: in ESM, `./foo.js` resolves to `./foo.ts`. Without this, + // changing foo.ts would not lint its dependents (this repo imports ESM + // scripts with `.js` specifiers that map to `.ts` sources). + if (/\.(js|cjs|mjs)$/.test(base)) { + const tsBase = base.replace(/\.(js|cjs|mjs)$/, ""); + candidates.unshift(`${tsBase}.ts`, `${tsBase}.tsx`); + } for (const candidate of candidates) { try { statSync(candidate); From 22c7659f85353a227e41597da4563123905fb122 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:48:29 +0500 Subject: [PATCH 30/36] fix(usage): estimate cost for missing bundled models instead of tracking $0 --- src/test/goUsageTracker.test.ts | 12 ++++++++---- src/usage/pricing.ts | 16 +++++++++++++--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/test/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 7148bee..62e5ab1 100644 --- a/src/test/goUsageTracker.test.ts +++ b/src/test/goUsageTracker.test.ts @@ -189,9 +189,12 @@ describe("goUsageTracker", () => { assert.equal(cost, 0.0002526); }); - it("returns 0 for an unknown model with no resolver", () => { + it("estimates a conservative cost for an unknown model with no resolver", () => { + // Unknown models use the fallback price (0.5 in / 2.0 out per 1M) instead + // of silently tracking $0 (which reads as free). const cost = estimateCost("nonexistent-model-v99", 100, 50, 0); - assert.equal(cost, 0); + assert.ok(cost > 0, "unknown model must not track as $0"); + assert.ok(cost < 0.001, "fallback estimate stays small"); }); it("prefers externalCost over the bundled table", () => { @@ -577,7 +580,7 @@ describe("goUsageTracker", () => { assert.equal(tracker.getRecentSessionCosts().length, 0); }); - it("handles unknown modelId (cost = 0)", () => { + it("estimates a conservative cost for unknown modelId", () => { const tracker = new GoUsageTracker(createMockContext()); tracker.record( makeSummary({ @@ -590,7 +593,8 @@ describe("goUsageTracker", () => { const session = tracker.getCurrentSessionCost(); assert.equal(session?.sessionId, "s1"); - assert.equal(session.cost, 0); + assert.ok(session.cost > 0, "unknown model must not track as $0"); + assert.ok(session.cost < 0.001, "fallback estimate stays small"); assert.equal(session.requests, 1); }); diff --git a/src/usage/pricing.ts b/src/usage/pricing.ts index 75546e7..a2a882e 100644 --- a/src/usage/pricing.ts +++ b/src/usage/pricing.ts @@ -7,14 +7,25 @@ export type CostResolver = (modelId: string) => ModelCost | undefined; // This table is a static snapshot kept as a last resort. The primary source // is the live models.dev metadata cache injected via CostResolver. +/** + * Conservative per-1M-token fallback for Go models absent from the bundled + * snapshot (e.g. a brand-new release) when the live models.dev resolver is + * also unavailable. Better a plausible estimate than a silent $0 (which reads + * as "free"). Replaced by the authoritative price as soon as a snapshot lands. + */ +const UNKNOWN_GO_MODEL_PRICE: ModelCost = { input: 0.5, output: 2.0, cache_read: 0.05 }; + 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.7-code": { input: 0.95, output: 4.0, cache_read: 0.16 }, // family estimate (same as k2.6) "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 }, + "minimax-m2.1": { input: 0.3, output: 1.2, cache_read: 0.06 }, // family estimate (same as m2.5) + "minimax-m2": { input: 0.3, output: 1.2, cache_read: 0.06 }, // family estimate (same as m2.5) "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 }, @@ -39,9 +50,8 @@ export function estimateCost( 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; + // Priority: caller-provided cost > live models.dev snapshot > bundled table > conservative fallback + const pricing = externalCost ?? liveCostResolver?.(modelId) ?? GO_MODEL_PRICING[modelId] ?? UNKNOWN_GO_MODEL_PRICE; const billablePrompt = Math.max(0, promptTokens - cachedTokens); return ( From edfd07137d33e110fc315a5d10ed01c3f5ff5154 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:49:35 +0500 Subject: [PATCH 31/36] fix(provider): use a real model family so VS Code family selection/grouping works --- src/provider/OpenCodeProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/provider/OpenCodeProvider.ts b/src/provider/OpenCodeProvider.ts index 5989ba7..96d80a0 100644 --- a/src/provider/OpenCodeProvider.ts +++ b/src/provider/OpenCodeProvider.ts @@ -11,6 +11,7 @@ import { type ResolvedModelMetadata, } from "../models/metadata"; import { resolveModelRouting } from "../core/routing"; +import { lookupModelRegistryEntry } from "../core/registry"; import { extractThinkingOverride, resolveThinkingConfig, thinkingFamily, thinkingProviderFor } from "../thinking"; import { buildOpenCodeGatewayAuthHeaders } from "../openCodeAuth"; import { streamAnthropicMessages as runStreamAnthropicMessages } from "../transports/anthropic"; @@ -621,7 +622,10 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider = { rawModelId: modelId, name: providerModelDisplayName(this.definition.modelNamePrefix, modelId, showProviderPrefix), - family: `${this.definition.isAgentVariant && this.definition.baseVendor ? this.definition.baseVendor : this.definition.vendor}-${modelId}-${MODEL_METADATA_REVISION}`, + // A stable real family name (e.g. "deepseek", "gpt") so VS Code's + // family-based model selection/grouping works — a per-model unique + // string previously broke `modelFamily` routing and sticky grouping. + family: lookupModelRegistryEntry(modelId).family, // Include effective limits in version so VS Code invalidates stale // picker metadata after limit changes (eg. 2M -> 262K corrections). version: `1.2.0-${MODEL_METADATA_REVISION}-${String(limits.contextWindow)}-${String(limits.maxOutputTokens)}`, From 1b169fa2e039bce470057a6a3f97c9816760bdcc Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:51:40 +0500 Subject: [PATCH 32/36] fix(usage): stop auto-resolving the active profile over the user's explicit choice --- src/config.ts | 2 ++ src/extension.ts | 4 ++++ src/usage/dashboard.ts | 16 ++++++++++++---- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/config.ts b/src/config.ts index bd31b70..8383da8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -235,6 +235,8 @@ export const COMPLETION_USAGE_MAX_DAYS = 370; export const PROFILES_REGISTRY_KEY = "opencodego.profiles.v1"; export const ACTIVE_PROFILE_KEY = "opencodego.activeProfile.v1"; +/** Set once the user explicitly picks a profile — auto-resolution must not override it. */ +export const ACTIVE_PROFILE_EXPLICIT_KEY = "opencodego.activeProfileExplicit.v1"; export const MIGRATED_KEY = "opencodego.migratedTo.v1"; export const LEGACY_SECRET_KEY = SECRET_KEY; export const LEGACY_FINGERPRINT = "legacy"; diff --git a/src/extension.ts b/src/extension.ts index 781bcce..2913bee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,6 +5,7 @@ import { showModelPickerDiagnostics } from "./commands/diagnostics"; import { showThinkingEffortPicker } from "./commands/thinkingPicker"; import { configureUtilityModels, toggleProviderEnabled } from "./commands/providers"; import { + ACTIVE_PROFILE_EXPLICIT_KEY, CONFIG_SECTION, DEFAULT_USAGE_CHART_DAYS, GO_EVER_TRACKED_KEY, @@ -330,6 +331,9 @@ export function activate(context: vscode.ExtensionContext) { if (activeProfileFingerprint === fp) { setActiveProfileFingerprint(LEGACY_FINGERPRINT); await writeActiveProfile(ctx, LEGACY_FINGERPRINT); + // The user's explicit choice was deleted — clear the flag so auto- + // selection resumes for the remaining profiles. + await ctx.globalState.update(ACTIVE_PROFILE_EXPLICIT_KEY, undefined); } refreshGoUsageStatusBar(); diff --git a/src/usage/dashboard.ts b/src/usage/dashboard.ts index 85b3a21..4972818 100644 --- a/src/usage/dashboard.ts +++ b/src/usage/dashboard.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { completionUsageToSeries, type CompletionUsageDay } from "../autocomplete/usage"; import { + ACTIVE_PROFILE_EXPLICIT_KEY, COMPLETION_USAGE_KEY, CONFIG_SECTION, DEFAULT_USAGE_CHART_DAYS, @@ -201,10 +202,13 @@ export function activeGoUsageTracker(): GoUsageTracker | undefined { return goUsageTrackers.get(activeProfileFingerprint); } -/** Switch the active profile and refresh the UI. */ +/** Switch the active profile and refresh the UI. Marks the choice as explicit. */ export async function setActiveProfile(fingerprint: string): Promise { activeProfileFingerprint = fingerprint; await writeActiveProfile(extensionContext(), fingerprint); + // Remember this was a deliberate user choice so provider/request resolution + // never silently overrides it (issue #51). + await extensionContext().globalState.update(ACTIVE_PROFILE_EXPLICIT_KEY, true); refreshGoUsageStatusBar(); updateWebviewContent(); } @@ -239,9 +243,13 @@ export function ensureProfileSync(apiKey: string): void { profilesCache = readProfiles(extensionContext()); } - // Update active profile to this one - activeProfileFingerprint = fp; - void writeActiveProfile(extensionContext(), fp); + // Update active profile to this one ONLY while the user hasn't explicitly + // chosen a profile — otherwise every ~300ms model-info resolution would + // silently override the user's selection. + if (!extensionContext().globalState.get(ACTIVE_PROFILE_EXPLICIT_KEY, false)) { + activeProfileFingerprint = fp; + void writeActiveProfile(extensionContext(), fp); + } } /** From a2e574534aa61f89d190667f342e56201a3d14c6 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:52:52 +0500 Subject: [PATCH 33/36] fix(errors): cap absurd rate-limit reset durations in error messages --- src/errors.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 1ca4234..eeb32c5 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -223,6 +223,9 @@ function parseRetryAfter(value: string | undefined): number | undefined { return Number.isFinite(dateMs) ? Math.max(0, dateMs - Date.now()) : parseDurationLike(value); } +/** Ceiling for a seconds-remaining reset value — anything larger is nonsense. */ +const MAX_RESET_REMAINING_SECONDS = 24 * 60 * 60; + function parseResetAfter(value: string | undefined): number | undefined { if (!value) { return undefined; @@ -230,12 +233,16 @@ function parseResetAfter(value: string | undefined): number | undefined { const numeric = Number(value); if (Number.isFinite(numeric) && numeric >= 0) { if (numeric > 1_000_000_000_000) { + // Epoch milliseconds (past timestamps clamp to 0). return Math.max(0, numeric - Date.now()); } if (numeric > 1_000_000_000) { + // Epoch seconds (past timestamps clamp to 0). return Math.max(0, numeric * 1000 - Date.now()); } - return numeric * 1000; + // Seconds remaining — cap so an absurd header value can't produce a + // multi-year "retry in" estimate in the error message. + return Math.min(numeric, MAX_RESET_REMAINING_SECONDS) * 1000; } const durationMs = parseDurationLike(value); if (durationMs !== undefined) { From 4e076b0e275a223c5335eb7b2ef1ab2ac28cb306 Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:54:13 +0500 Subject: [PATCH 34/36] refactor(thinking): derive picker options from the shared validated allowlist --- src/commands/thinkingPicker.ts | 23 ++++++++++++----------- src/provider/settings.ts | 2 +- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/commands/thinkingPicker.ts b/src/commands/thinkingPicker.ts index 58333dd..4446ca3 100644 --- a/src/commands/thinkingPicker.ts +++ b/src/commands/thinkingPicker.ts @@ -1,19 +1,20 @@ import * as vscode from "vscode"; import { CONFIG_SECTION } from "../config"; -import { getSettings } from "../provider/settings"; -import type { ThinkingSettings } from "../thinking"; +import { getSettings, THINKING_ALLOWED_VALUES } from "../provider/settings"; /** Pick a model family then set its Thinking effort (writes config). */ export async function showThinkingEffortPicker(): Promise { - const families: { label: string; key: keyof ThinkingSettings; options: string[] }[] = [ - { label: "DeepSeek (deepseek-v4-*)", key: "deepseek", options: ["off", "low", "medium", "high", "max"] }, - { label: "GLM (glm-5, glm-5.1, glm-5.2)", key: "glm", options: ["off", "high", "max"] }, - { label: "Kimi (kimi-k2.*)", key: "kimi", options: ["on", "off"] }, - { label: "Mimo (mimo-v2.*)", key: "mimo", options: ["off", "low", "medium", "high"] }, - { label: "MiniMax (minimax-m*)", key: "minimax", options: ["off", "on"] }, - { label: "OpenAI GPT (gpt-*)", key: "openai", options: ["off", "low", "medium", "high", "xhigh"] }, - { label: "Qwen (qwen3.*)", key: "qwen", options: ["auto", "on", "off"] }, - { label: "Qwen Thinking Budget", key: "qwenBudget", options: ["auto", "4096", "16384", "32768", "81920"] }, + // Single source of truth (shared with request-time validation) so the option + // lists can never drift from what the request builder actually accepts. + const families: { label: string; key: keyof typeof THINKING_ALLOWED_VALUES; options: string[] }[] = [ + { label: "DeepSeek (deepseek-v4-*)", key: "deepseek", options: [...THINKING_ALLOWED_VALUES.deepseek] }, + { label: "GLM (glm-5, glm-5.1, glm-5.2)", key: "glm", options: [...THINKING_ALLOWED_VALUES.glm] }, + { label: "Kimi (kimi-k2.*)", key: "kimi", options: [...THINKING_ALLOWED_VALUES.kimi] }, + { label: "Mimo (mimo-v2.*)", key: "mimo", options: [...THINKING_ALLOWED_VALUES.mimo] }, + { label: "MiniMax (minimax-m*)", key: "minimax", options: [...THINKING_ALLOWED_VALUES.minimax] }, + { label: "OpenAI GPT (gpt-*)", key: "openai", options: [...THINKING_ALLOWED_VALUES.openai] }, + { label: "Qwen (qwen3.*)", key: "qwen", options: [...THINKING_ALLOWED_VALUES.qwen] }, + { label: "Qwen Thinking Budget", key: "qwenBudget", options: [...THINKING_ALLOWED_VALUES.qwenBudget] }, ]; const settings = getSettings().thinking; const family = await vscode.window.showQuickPick( diff --git a/src/provider/settings.ts b/src/provider/settings.ts index 45f89f5..358f177 100644 --- a/src/provider/settings.ts +++ b/src/provider/settings.ts @@ -32,7 +32,7 @@ import { toFiniteNumber } from "../utils"; import type { LanguageModelConfiguration, ProviderDefinition } from "./definitions"; /** Allowed values per thinking setting — a misconfigured value must never reach the wire. */ -const THINKING_ALLOWED_VALUES = { +export const THINKING_ALLOWED_VALUES = { deepseek: ["off", "low", "medium", "high", "max"], glm: ["off", "high", "max"], kimi: ["on", "off"], From bb1617fee1e1379b2e65809cfdfae0847f55d70e Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Fri, 14 Aug 2026 23:54:59 +0500 Subject: [PATCH 35/36] fix(diagnostics): one failing vendor can't abort the whole picker report --- src/commands/diagnostics.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/commands/diagnostics.ts b/src/commands/diagnostics.ts index ad52092..f9a0bae 100644 --- a/src/commands/diagnostics.ts +++ b/src/commands/diagnostics.ts @@ -11,7 +11,15 @@ export async function showModelPickerDiagnostics(): Promise { const sections: string[] = []; for (const vendor of vendors) { - const models = await vscode.lm.selectChatModels({ vendor }); + let models: readonly vscode.LanguageModelChat[]; + try { + models = await vscode.lm.selectChatModels({ vendor }); + } catch (error) { + // One failing vendor (e.g. no Copilot models installed) must not abort + // the whole diagnostics report. + sections.push(`## vendor: ${vendor}`, "", `selection error: ${error instanceof Error ? error.message : String(error)}`, ""); + continue; + } sections.push(`## vendor: ${vendor}`, "", `models: ${String(models.length)}`, ""); for (const model of models) { const internalModel = model as unknown as { configurationSchema?: unknown; detail?: unknown }; From eff395a016219487f59b4cd1e7f533fa488e3a6e Mon Sep 17 00:00:00 2001 From: Fahad Iftikhar Date: Mon, 17 Aug 2026 07:40:30 +0500 Subject: [PATCH 36/36] docs(usage): point active-profile override comment at the right issue (#63) --- src/usage/dashboard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/usage/dashboard.ts b/src/usage/dashboard.ts index 4972818..dfbda6a 100644 --- a/src/usage/dashboard.ts +++ b/src/usage/dashboard.ts @@ -207,7 +207,7 @@ export async function setActiveProfile(fingerprint: string): Promise { activeProfileFingerprint = fingerprint; await writeActiveProfile(extensionContext(), fingerprint); // Remember this was a deliberate user choice so provider/request resolution - // never silently overrides it (issue #51). + // never silently overrides it (issue #63). await extensionContext().globalState.update(ACTIVE_PROFILE_EXPLICIT_KEY, true); refreshGoUsageStatusBar(); updateWebviewContent();