diff --git a/CHANGELOG.md b/CHANGELOG.md index 1953a45..1a0475c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ## [Unreleased] -### Fixed +### Added + +- **`[Usage]` Per-profile Go usage tracking for multi-account setups (issue #63).** Users can now add multiple OpenCode Go entries in the Manage Language Models panel, each with its own API key. The extension automatically creates a "Profile 1", "Profile 2", etc. on the first request sent with each key. Profiles have isolated storage and the active profile auto-switches to the most recently used model. The SVG hover card and status bar show the active profile label. Click the status bar to open the QuickPick with profile switch options. Commands: `Rename Active Profile` and `Delete Active Profile`. In single-key mode the extension behaves exactly as before; the feature only activates when a second key is detected. No SQLite consultation in multi-key mode (opencode.db is shared per-machine and cannot be attributed to a specific key). -- **`[Usage]` Monthly cost aggregation now respects the subscription anchor.** The monthly window was using calendar month after a regression, but OpenCode Go billing is anchor-based (subscription day/hour). The tracker now derives the window from three tiers: (1) user-configured anchor via "Set spent targets", (2) auto-anchor from the earliest SQLite row (matching actual billing start), (3) calendar month fallback. Also fixes `setManualSpentTargets` which previously computed the monthly cost for the old window before storing the anchor, causing tracked+baseline to mismatch the target. Now reads SQLite costs directly using the prospective window (with the new anchor). ## [0.3.7] — 2026-07-09 @@ -18,6 +19,10 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente - **`[Streaming]` `[stream-summary]` log now reports total reasoning characters accurately.** Previously, `reasoningChars` in the log reflected only the remaining `reasoningContent` string, which is cleared by `flushToolCalls` (for tool-call replication) and `flushReasoningFallback` — so the metric showed `0` even when reasoning was streamed. Now tracks a monotonic `totalReasoningChars` counter that survives clears, giving accurate per-response reasoning metrics for debugging. +### Fixed + +- **`[Usage]` Monthly cost aggregation now respects the subscription anchor.** The monthly window was using calendar month after a regression, but OpenCode Go billing is anchor-based (subscription day/hour). The tracker now derives the window from three tiers: (1) user-configured anchor via "Set spent targets", (2) auto-anchor from the earliest SQLite row (matching actual billing start), (3) calendar month fallback. Also fixes `setManualSpentTargets` which previously computed the monthly cost for the old window before storing the anchor, causing tracked+baseline to mismatch the target. Now reads SQLite costs directly using the prospective window (with the new anchor). + ## [0.3.6] — 2026-07-08 ### Fixed diff --git a/README.md b/README.md index b3816fc..1ac67a2 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,32 @@ Per-model reasoning configuration, dynamically enhanced with `reasoning_options` - **Response usage bar** — latest prompt/output/total/cache summary after each response. - **Normalized usage DataPart** — emits `usage` MIME so Copilot Chat's context widget shows accurate token counts. +#### Multiple Go accounts + +You can use more than one OpenCode Go account in the same VS Code window. +When a **second** Go API key is added via the Manage Language Models panel, +the extension creates a new usage profile ("Profile 1", "Profile 2", etc.) +on the first request made with each key. + +- **Auto-switch** — the status bar and SVG hover card follow the model you + last used. If you chat with a model from a different account, the panel + switches automatically. +- **QuickPick** — click the status bar to see which profile is active and + switch to another profile. +- **Commands** — `OpenCode Go: Rename Active Profile` and `OpenCode Go: + Delete Active Profile` help you manage your profiles. The label you give + a profile appears in the status bar and SVG title. +- **Storage isolation** — each profile has its own `globalState` storage + namespace (`opencodego.usageLog.v1.`) so data never mixes. +- **SQLite** — when 2+ profiles exist, the extension does not consult the + shared `opencode.db` (which is per-machine and cannot be attributed to a + specific API key). Accuracy falls back to in-memory entries for + multi-account setups. + +> **Upgrading from a single-account install?** Your existing usage data +> is migrated into Profile 1 the first time a second profile is created. +> Your original stats are preserved — nothing is lost. + ### 🪟 Agents Window (Copilot CLI) Support OpenCode models appear in the VS Code **Agents window** model picker when starting a Copilot CLI / Background agent session — not just the regular Chat view. Two sets of models are available: diff --git a/package.json b/package.json index 5520fb0..78fdc39 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,14 @@ { "command": "opencodego.showUsageQuickPick", "title": "OpenCode Go: Show Usage Quick Pick" + }, + { + "command": "opencodego.renameActiveProfile", + "title": "OpenCode Go: Rename Active Profile" + }, + { + "command": "opencodego.deleteProfile", + "title": "OpenCode Go: Delete Profile" } ], "configuration": { diff --git a/src/extension.ts b/src/extension.ts index 614b200..b6c01b6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -60,17 +60,115 @@ import { estimateCost, type UsageBaselineTargets, } from "./goUsageTracker"; +import { + LEGACY_FINGERPRINT, + keyFingerprint, + readActiveProfile, + readProfiles, + writeActiveProfile, + writeProfiles, + readActiveProfiles, + readMigratedTo, + writeMigratedTo, + findProfile, + renameProfile, + nonLegacyCount, + type UsageProfile, +} from "./usageProfile"; const SECRET_KEY = "opencodego.apiKey"; +const SECRET_STORE_KEY = "opencodego.activeApiKey"; const RECENT_TRANSPORT_SUMMARY_LIMIT = 25; const RECENT_TRANSPORT_SUMMARY_STORAGE_PREFIX = "opencode.recentTransportSummaries"; let usageStatusBarItem: vscode.StatusBarItem | undefined; let goUsageStatusBarItem: vscode.StatusBarItem | undefined; - +/** Singleton tracker — the first/legacy account. Used for backward compat until first migration. */ let goUsageTracker: GoUsageTracker | undefined; +/** Per-profile trackers indexed by key fingerprint. */ +const goUsageTrackers: Map = new Map(); let usageWebviewPanel: vscode.WebviewPanel | undefined; +let profilesCache: UsageProfile[] = []; +let activeProfileFingerprint: string = LEGACY_FINGERPRINT; + +/** Look up (or create) the GoUsageTracker for a given key fingerprint. */ +function getOrCreateTracker(fingerprint: string): GoUsageTracker { + // The singleton tracker does not have a storage suffix + if (fingerprint === LEGACY_FINGERPRINT && goUsageTracker) return goUsageTracker; + let tracker = goUsageTrackers.get(fingerprint); + if (tracker) return tracker; + tracker = new GoUsageTracker( + _extensionContext!, + (msg) => _usageLogChannel!.appendLine(`[${new Date().toISOString()}] [${fingerprint}] ${msg}`), + (modelId) => modelMetadataSnapshot?.providers[GO_VENDOR]?.[modelId]?.cost, + fingerprint, + ); + goUsageTrackers.set(fingerprint, tracker); + return tracker; +} + +/** Return the tracker for the currently active profile. */ +function activeGoUsageTracker(): GoUsageTracker | undefined { + if (activeProfileFingerprint === LEGACY_FINGERPRINT) return goUsageTracker; + return goUsageTrackers.get(activeProfileFingerprint); +} + +/** Switch the active profile and refresh the UI. */ +async function setActiveProfile(fingerprint: string): Promise { + activeProfileFingerprint = fingerprint; + await writeActiveProfile(_extensionContext!, fingerprint); + refreshGoUsageStatusBar(); + updateWebviewContent(); +} + +/** + * Ensure a profile exists in the in-memory cache for the given API key. + * This is called both from provideLanguageModelChatInformation (at startup, + * when VS Code resolves all providers) and from onTransportSummary (when + * a request completes). The first call creates the profile; subsequent + * calls are no-ops. Persistence is fire-and-forget. + */ +function ensureProfileSync(apiKey: string): void { + const fp = keyFingerprint(apiKey); + const tracker = getOrCreateTracker(fp); + + if (!findProfile(profilesCache, fp)) { + const nextNumber = nonLegacyCount(profilesCache) + 1; + profilesCache.push({ + fingerprint: fp, + label: `Profile ${nextNumber}`, + lastSeenAt: Date.now(), + }); + writeProfiles(_extensionContext!, profilesCache); + } + + // One-time migration from singleton + if (!readMigratedTo(_extensionContext!)) { + if (goUsageTracker && fp !== LEGACY_FINGERPRINT) { + tracker.migrateFromSingleton(); + } + writeMigratedTo(_extensionContext!, fp); + profilesCache = readProfiles(_extensionContext!); + } + + // Update active profile to this one + activeProfileFingerprint = fp; + writeActiveProfile(_extensionContext!, fp); +} + +/** + * Same as ensureProfileSync, but also refreshes the UI. + * Called from onTransportSummary during request recording. + */ +function ensureProfileForApiKey(apiKey: string, _displayName: string): GoUsageTracker { + ensureProfileSync(apiKey); + return getOrCreateTracker(keyFingerprint(apiKey)); +} + +let _extensionContext: vscode.ExtensionContext; +let _usageLogChannel: vscode.OutputChannel; + interface ProviderDefinition { vendor: AllProviderVendor; displayName: string; @@ -444,6 +542,17 @@ export function activate(context: vscode.ExtensionContext) { }, (modelId) => { return modelMetadataSnapshot?.providers[GO_VENDOR]?.[modelId]?.cost; }); + _extensionContext = context; + _usageLogChannel = goUsageLogChannel; + profilesCache = readProfiles(context); + activeProfileFingerprint = readActiveProfile(context); + + // Eagerly load the tracker for the active profile so the status bar + // has data to display immediately, even before the first request. + if (activeProfileFingerprint !== LEGACY_FINGERPRINT) { + getOrCreateTracker(activeProfileFingerprint); + } + ensureUsageStatusBar(context); ensureGoUsageStatusBar(context); const goProvider = new OpenCodeProvider(context, PROVIDERS[GO_VENDOR]); @@ -460,21 +569,22 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("opencodego.setThinkingEffort", () => showThinkingEffortPicker()), vscode.commands.registerCommand("opencodego.showUsageDetails", () => showUsageWebview(context)), vscode.commands.registerCommand("opencodego.setUsageTargets", async () => { - if (!goUsageTracker) return; - const targets = await showUsageTargetEditor(goUsageTracker); + const tracker = activeGoUsageTracker(); + if (!tracker) return; + const targets = await showUsageTargetEditor(tracker); if (targets) { - goUsageTracker.setManualSpentTargets(targets); - refreshGoUsageStatusBar(); // Update tooltip immediately + tracker.setManualSpentTargets(targets); + refreshGoUsageStatusBar(); vscode.window.showInformationMessage("OpenCode Go usage targets updated."); } }), vscode.commands.registerCommand("opencodego.showUsageQuickPick", async () => { - if (!goUsageTracker) return; - const summary = goUsageTracker.getSummary(); + const tracker = activeGoUsageTracker(); + if (!tracker) return; + const summary = tracker.getSummary(); const items = buildUsageQuickPickItems(summary); - // Prepend current chat session cost alongside today/yesterday - const sessionCost = goUsageTracker.getCurrentSessionCost(); + const sessionCost = tracker.getCurrentSessionCost(); if (sessionCost && sessionCost.cost > 0) { const totalTokens = sessionCost.promptTokens + sessionCost.completionTokens; const sessionItem: vscode.QuickPickItem = { @@ -483,7 +593,6 @@ export function activate(context: vscode.ExtensionContext) { detail: `${tokens(totalTokens)} tokens · ${sessionCost.requests} requests`, alwaysShow: true, }; - // Insert at the top of the "Daily Summary" section (after the last period bar) const dailyIdx = items.findIndex(i => i.kind === vscode.QuickPickItemKind.Separator && i.label === "Daily Summary"); if (dailyIdx >= 0) { items.splice(dailyIdx + 1, 0, sessionItem); @@ -492,6 +601,29 @@ export function activate(context: vscode.ExtensionContext) { } } + // Profile switching section — visible when 2+ profiles exist. + // Lists ALL profiles; the active one is marked and serves as + // a no-op picker label while others are clickable switches. + if (profilesCache.length > 1) { + items.push({ label: "", kind: vscode.QuickPickItemKind.Separator }); + for (const p of profilesCache) { + if (p.fingerprint === activeProfileFingerprint) { + items.push({ + label: `$(check) ${p.label} (active)`, + _fp: p.fingerprint, + _action: "none", + } as vscode.QuickPickItem & { _fp?: string; _action?: string }); + } else { + items.push({ + label: ` Switch to ${p.label}`, + _fp: p.fingerprint, + _action: "switchProfile", + } as vscode.QuickPickItem & { _fp?: string; _action?: string }); + } + } + items.push({ label: "", kind: vscode.QuickPickItemKind.Separator }); + } + const separator: vscode.QuickPickItem = { label: "", kind: vscode.QuickPickItemKind.Separator }; const setTargetItem: vscode.QuickPickItem & { _action?: string } = { label: "$(edit) Set spent targets…", _action: "setUsageTargets" }; const panelItem: vscode.QuickPickItem & { _action?: string } = { label: "$(graph) Open full usage panel", _action: "showUsageDetails" }; @@ -501,12 +633,77 @@ export function activate(context: vscode.ExtensionContext) { title: "Usage Summary", }); if (!picked || !("_action" in picked)) return; - const action = (picked as typeof setTargetItem)._action; + const action = (picked as { _action: string })._action; if (action === "setUsageTargets") { vscode.commands.executeCommand("opencodego.setUsageTargets"); } else if (action === "showUsageDetails") { vscode.commands.executeCommand("opencodego.showUsageDetails"); + } else if (action === "switchProfile" && "_fp" in picked) { + setActiveProfile((picked as { _fp: string })._fp); + } + }), + vscode.commands.registerCommand("opencodego.renameActiveProfile", async () => { + const active = findProfile(profilesCache, activeProfileFingerprint); + if (!active) { + vscode.window.showInformationMessage("No active profile to rename."); + return; } + const newLabel = await vscode.window.showInputBox({ + title: "Rename Go Profile", + prompt: `Current label: ${active.label}`, + value: active.label, + placeHolder: "e.g. OpenCode Go (Works)", + }); + if (!newLabel || !newLabel.trim()) return; + await renameProfile(_extensionContext, activeProfileFingerprint, newLabel); + profilesCache = readProfiles(_extensionContext); + refreshGoUsageStatusBar(); + updateWebviewContent(); + vscode.window.showInformationMessage(`Profile renamed to "${newLabel}".`); + }), + vscode.commands.registerCommand("opencodego.deleteProfile", async () => { + const profiles = readActiveProfiles(_extensionContext); + if (profiles.length === 0) { + vscode.window.showInformationMessage("No profiles to delete."); + return; + } + const picked = await vscode.window.showQuickPick( + profiles.map(p => ({ + label: p.label, + description: `fingerprint: ${p.fingerprint}`, + _fp: p.fingerprint, + })), + { placeHolder: "Select a profile to delete" }, + ); + if (!picked || !("_fp" in picked)) return; + const fp = (picked as { _fp: string })._fp; + const profile = findProfile(profiles, fp); + if (!profile) return; + const confirm = await vscode.window.showWarningMessage( + `Permanently delete profile "${profile.label}"? Its usage history will be removed. This cannot be undone.`, + { modal: true }, + "Delete", + ); + if (confirm !== "Delete") return; + + goUsageTrackers.delete(fp); + const ctx = _extensionContext; + ctx.globalState.update(`opencodego.usageLog.v1.${fp}`, []); + ctx.globalState.update(`opencodego.usageBaseline.v1.${fp}`, {}); + ctx.globalState.update(`opencodego.sessionCosts.v1.${fp}`, []); + + const remaining = readProfiles(ctx).filter(p => p.fingerprint !== fp); + await writeProfiles(ctx, remaining); + profilesCache = remaining; + + if (activeProfileFingerprint === fp) { + activeProfileFingerprint = LEGACY_FINGERPRINT; + await writeActiveProfile(ctx, LEGACY_FINGERPRINT); + } + + refreshGoUsageStatusBar(); + updateWebviewContent(); + vscode.window.showInformationMessage(`Profile "${profile.label}" deleted.`); }), ]; @@ -734,10 +931,21 @@ function ensureGoUsageStatusBar(context: vscode.ExtensionContext): void { function refreshGoUsageStatusBar(): void { - if (!goUsageStatusBarItem || !goUsageTracker) return; - const s = goUsageTracker.getSummary(); - goUsageStatusBarItem.text = formatGoUsageStatusBarText(s); - goUsageStatusBarItem.tooltip = buildUsageTooltip(s, goUsageTracker.getCurrentSessionCost()); + if (!goUsageStatusBarItem) return; + const tracker = activeGoUsageTracker(); + if (!tracker) { + goUsageStatusBarItem.text = "OpenCode Go"; + goUsageStatusBarItem.tooltip = new vscode.MarkdownString(""); + goUsageStatusBarItem.show(); + return; + } + const s = tracker.getSummary(); + const activeProfile = findProfile(profilesCache, activeProfileFingerprint); + const baseText = formatGoUsageStatusBarText(s); + goUsageStatusBarItem.text = activeProfile && profilesCache.length > 1 + ? `${baseText} [${activeProfile.label}]` + : baseText; + goUsageStatusBarItem.tooltip = buildUsageTooltip(s, tracker.getCurrentSessionCost()); goUsageStatusBarItem.show(); updateWebviewContent(); } @@ -767,8 +975,15 @@ function showUsageWebview(context: vscode.ExtensionContext): void { function updateWebviewContent(): void { if (!usageWebviewPanel || !goUsageTracker) return; - const s = goUsageTracker.getSummary(); - const sc = goUsageTracker.getCurrentSessionCost(); + const tracker = activeGoUsageTracker(); + if (!tracker) { + usageWebviewPanel.webview.html = `

No active tracker

`; + return; + } + const s = tracker.getSummary(); + const sc = tracker.getCurrentSessionCost(); + const activeProfile = findProfile(profilesCache, activeProfileFingerprint); + const profileLabel = activeProfile?.label ?? "OpenCode Go"; usageWebviewPanel.webview.html = ` @@ -776,7 +991,7 @@ function updateWebviewContent(): void { - OpenCode Usage Summary + OpenCode Usage Summary — ${escapeSvg(profileLabel)}