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); 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); }); 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/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 }; 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/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/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, 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) { diff --git a/src/extension.ts b/src/extension.ts index d5952d3..2913bee 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,8 +5,11 @@ 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, + GO_SERVER_USAGE_KEY, SETTING_AGENTS_WINDOW, SETTING_AUTO_ENABLE_AGENTS_WINDOW, SETTING_SHOW_PROVIDER_PREFIX, @@ -316,6 +319,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); @@ -324,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(); @@ -376,10 +386,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); } } 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 = { 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)}`, @@ -971,6 +975,7 @@ export class OpenCodeProvider implements vscode.LanguageModelChatProvider "mimo-v2-pro", "mimo-v2.5", "mimo-v2.5-pro", + "minimax-m3", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", diff --git a/src/provider/messages.ts b/src/provider/messages.ts index aae75bf..10b8413 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); @@ -307,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 }); diff --git a/src/provider/settings.ts b/src/provider/settings.ts index a8fdb34..358f177 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. */ +export 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; @@ -90,28 +107,65 @@ 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), + // 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"), }; 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); diff --git a/src/request/schema.ts b/src/request/schema.ts index 8006b0d..9a9f2fa 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: {} }; } @@ -21,64 +21,97 @@ 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 } : {}), }; } -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", + "const", + "pattern", + "format", + "default", + "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/goUsageTracker.test.ts b/src/test/goUsageTracker.test.ts index 7337f08..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); }); @@ -876,4 +880,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/test/schema.test.ts b/src/test/schema.test.ts new file mode 100644 index 0000000..7844dd7 --- /dev/null +++ b/src/test/schema.test.ts @@ -0,0 +1,93 @@ +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: {} }); + }); + + 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" }); + }); +}); 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/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"); 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/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)}`, diff --git a/src/transports/engine.ts b/src/transports/engine.ts index 6941f20..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) => @@ -198,7 +199,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,10 +223,21 @@ 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; } + // 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 || ""}`); diff --git a/src/transports/extract.ts b/src/transports/extract.ts index 69bfe85..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)); } @@ -72,12 +77,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") { 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; } 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.`, + ); + } } diff --git a/src/usage/dashboard.ts b/src/usage/dashboard.ts index 08c46da..dfbda6a 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 #63). + 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); + } } /** @@ -699,6 +707,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]); 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); } diff --git a/src/usage/history.ts b/src/usage/history.ts index ef617cd..ae6410d 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; @@ -346,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"], }); 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 ( 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 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 ───────────────────────────────────────────────────────────