From 27f393064ad7868c37634d173d4fd832ec781df4 Mon Sep 17 00:00:00 2001 From: Agung Maulana Date: Wed, 15 Jul 2026 15:11:33 +0700 Subject: [PATCH 1/2] feat(engine): add usage quota tracking and indicator ui for ai providers Introduce a unified usage quota system across all supported AI engine adapters (Claude Code, Copilot, Codex, OpenCode). Each adapter parses provider-specific usage output (CLI commands, API responses, or database queries) into a common UsageSnapshot schema with typed UsageQuota entries. A new engine:get-usage WebSocket endpoint exposes snapshots to the frontend, where a compact UsageIndicator component displays remaining quota percentages in the chat panel header. The indicator adapts to narrow layouts via container queries and offers a detail popover with per-quota breakdowns, color-coded thresholds, reset timestamps, and a manual refresh button. Previously users had no visibility into their consumption limits; this change provides real-time awareness to prevent unexpected service interruptions. --- backend/engine/adapters/claude/usage.test.ts | 67 ++++ backend/engine/adapters/claude/usage.ts | 292 ++++++++++++++++++ backend/engine/adapters/codex/usage.ts | 136 ++++++++ backend/engine/adapters/copilot/usage.ts | 89 ++++++ backend/engine/adapters/opencode/usage.ts | 163 ++++++++++ backend/ws/engine/index.ts | 4 +- backend/ws/engine/usage.ts | 81 +++++ .../chat/widgets/ContextIndicator.svelte | 8 +- .../chat/widgets/UsageIndicator.svelte | 199 ++++++++++++ .../components/workspace/PanelHeader.svelte | 2 + shared/types/unified/engine.ts | 18 ++ shared/types/unified/index.ts | 2 + 12 files changed, 1056 insertions(+), 5 deletions(-) create mode 100644 backend/engine/adapters/claude/usage.test.ts create mode 100644 backend/engine/adapters/claude/usage.ts create mode 100644 backend/engine/adapters/codex/usage.ts create mode 100644 backend/engine/adapters/copilot/usage.ts create mode 100644 backend/engine/adapters/opencode/usage.ts create mode 100644 backend/ws/engine/usage.ts create mode 100644 frontend/components/chat/widgets/UsageIndicator.svelte diff --git a/backend/engine/adapters/claude/usage.test.ts b/backend/engine/adapters/claude/usage.test.ts new file mode 100644 index 00000000..827f068d --- /dev/null +++ b/backend/engine/adapters/claude/usage.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test'; +import { _test } from './usage'; + +describe('Claude Code Usage Probe Parser Helpers', () => { + test('stripAnsi removes escape codes', () => { + const raw = '\x1B[1mCurrent session:\x1B[0m \x1B[32m45% left\x1B[0m'; + expect(_test.stripAnsi(raw)).toBe('Current session: 45% left'); + }); + + test('percentFromLine handles used and left strings', () => { + expect(_test.percentFromLine(' 45% left')).toBe(45); + expect(_test.percentFromLine(' 20% used')).toBe(80); + expect(_test.percentFromLine(' 0% used')).toBe(100); + expect(_test.percentFromLine(' 100% used')).toBe(0); + expect(_test.percentFromLine('no percent here')).toBeNull(); + }); + + test('extractPercent finds percentages in windows', () => { + const text = ` + Some header text + Current session: + ---------------- + 45% left + Other stuff + `; + expect(_test.extractPercent('Current session', text)).toBe(45); + expect(_test.extractPercent('Non existent', text)).toBeNull(); + }); + + test('extractReset extracts reset text', () => { + const text = ` + Current session: + 45% left + Resets in 2h 15m + `; + expect(_test.extractReset('Current session', text)).toBe('Resets in 2h 15m'); + }); + + test('deduplicateResetText removes duplicate repeats', () => { + const raw = 'Resets 4:59pm (America/New_York)Resets 4:59pm (America/New_York)'; + expect(_test.deduplicateResetText(raw)).toBe('Resets 4:59pm (America/New_York)'); + }); + + test('parseResetDate handles relative reset durations', () => { + const dateStr = _test.parseResetDate('in 2h 15m'); + expect(dateStr).not.toBeNull(); + if (dateStr) { + const parsed = new Date(dateStr); + const diffMs = parsed.getTime() - Date.now(); + // should be roughly 2h 15m (8100 seconds) + expect(diffMs / 1000).toBeGreaterThan(8000); + expect(diffMs / 1000).toBeLessThan(8200); + } + }); + + test('parseResetDate handles absolute dates', () => { + // Test date parsing for relative stability + const year = new Date().getFullYear(); + const dateStr = _test.parseResetDate(`Dec 25, ${year}, 4:59pm`); + expect(dateStr).not.toBeNull(); + if (dateStr) { + const parsed = new Date(dateStr); + expect(parsed.getUTCMonth()).toBe(11); // December (0-indexed) + expect(parsed.getUTCDate()).toBe(25); + } + }); +}); diff --git a/backend/engine/adapters/claude/usage.ts b/backend/engine/adapters/claude/usage.ts new file mode 100644 index 00000000..6cd938e2 --- /dev/null +++ b/backend/engine/adapters/claude/usage.ts @@ -0,0 +1,292 @@ +import { spawn } from 'bun'; +import { join } from 'path'; +import { getClaudeUserConfigDir, getEngineEnv, setupEnvironmentOnce } from './environment'; +import { resolveBinaryWithRefresh } from '../../../utils/cli'; +import { getClopenDir } from '../../../utils/paths'; +import { debug } from '$shared/utils/logger'; +import type { UsageSnapshot, UsageQuota } from '$shared/types/unified/engine'; + +function stripAnsi(text: string): string { + return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ''); +} + +function percentFromLine(line: string): number | null { + const match = line.match(/([0-9]{1,3})\s*%\s*(used|left)/i); + if (!match) return null; + const val = parseInt(match[1], 10); + const isUsed = match[2].toLowerCase().includes('used'); + return isUsed ? Math.max(0, 100 - val) : val; +} + +function extractPercent(labelSubstring: string, text: string): number | null { + const lines = text.split('\n'); + const label = labelSubstring.toLowerCase(); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(label)) { + // Search subsequent 12 lines for a percentage + const window = lines.slice(i, i + 12); + for (const candidate of window) { + const pct = percentFromLine(candidate); + if (pct !== null) { + return pct; + } + } + } + } + return null; +} + +function extractReset(labelSubstring: string, text: string): string | null { + const lines = text.split('\n'); + const label = labelSubstring.toLowerCase(); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(label)) { + // Search subsequent 14 lines for a reset duration or time + const window = lines.slice(i, i + 14); + for (const candidate of window) { + const lower = candidate.toLowerCase(); + if (lower.includes('reset') || (lower.includes('in') && (lower.includes('h') || lower.includes('m')))) { + return deduplicateResetText(candidate.trim()); + } + } + } + } + return null; +} + +function deduplicateResetText(text: string): string { + const matches = [...text.matchAll(/resets/gi)]; + if (matches.length > 1) { + const lastIdx = matches[matches.length - 1].index; + if (lastIdx !== undefined) { + return text.substring(lastIdx).trim(); + } + } + return text; +} + +function parseResetDate(text: string | null): string | null { + if (!text) return null; + + const now = new Date(); + let totalSeconds = 0; + + // Extract days + const dayMatch = text.match(/(\d+)\s*d(?:ays?)?/i); + if (dayMatch) { + totalSeconds += parseInt(dayMatch[1], 10) * 24 * 3600; + } + + // Extract hours + const hourMatch = text.match(/(\d+)\s*h(?:ours?|r)?/i); + if (hourMatch) { + totalSeconds += parseInt(hourMatch[1], 10) * 3600; + } + + // Extract minutes + const minMatch = text.match(/(\d+)\s*m(?:in(?:utes?)?)?/i); + if (minMatch) { + totalSeconds += parseInt(minMatch[1], 10) * 60; + } + + if (totalSeconds > 0) { + return new Date(now.getTime() + totalSeconds * 1000).toISOString(); + } + + // Absolute date/time parser fallback: e.g. "4:59pm (America/New_York)" + // Strip "resets" and timezone parenthetical + let cleaned = text.replace(/resets/gi, ''); + cleaned = cleaned.replace(/\s+\d{1,3}%\s*(?:used|left)\s*$/i, ''); // Strip percentage + cleaned = cleaned.replace(/\s*\([^)]+\)\s*$/i, ''); // Strip (timezone) + cleaned = cleaned.replace(/\s+at\s+/g, ', ').trim(); + cleaned = cleaned.replace(/(\d+(?::\d+)?)\s*(am|pm)/i, '$1 $2'); + + // Parse simple absolute date/time + const parsed = Date.parse(cleaned); + if (!isNaN(parsed)) { + return new Date(parsed).toISOString(); + } + + return null; +} + +export async function getClaudeUsage(accountId?: number): Promise { + await setupEnvironmentOnce(); + const binary = await resolveBinaryWithRefresh('claude'); + if (!binary) { + throw new Error('Claude Code binary not found in PATH'); + } + + const configDir = getClaudeUserConfigDir(); + const probeDir = join(getClopenDir(), 'engine', 'claude', 'probe'); + + // Ensure probe directory exists + try { + await Bun.write(join(probeDir, '.init'), ''); + } catch { + // Ignore folder creation check + } + + // Write project trust dialog acceptance to .claude.json in configDir + const claudeJsonPath = join(configDir, '.claude.json'); + try { + let configJson: any = {}; + const file = Bun.file(claudeJsonPath); + if (await file.exists()) { + configJson = await file.json(); + } + if (!configJson.projects) { + configJson.projects = {}; + } + configJson.projects[probeDir] = { hasTrustDialogAccepted: true }; + await Bun.write(claudeJsonPath, JSON.stringify(configJson, null, 2)); + } catch (err) { + debug.warn('engine', 'Failed to pre-trust probe folder in claude.json:', err); + } + + debug.log('engine', `Running claude /usage in directory: ${probeDir}`); + const env = getEngineEnv(accountId); + + // Run /usage command + const proc = spawn({ + cmd: [binary, '/usage', '--allowed-tools', ''], + cwd: probeDir, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + + const exitCode = await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + const output = stdout + '\n' + stderr; + const clean = stripAnsi(output); + + debug.log('engine', `Claude usage output:\n${clean}`); + + // Check if payment/api plan indicates usage is not supported via /usage + if (clean.toLowerCase().includes('/usage is only available for subscription') || + clean.toLowerCase().includes('subscription plans') || + exitCode !== 0) { + debug.log('engine', 'Subscription not found or /usage failed, falling back to /cost...'); + // Fallback to /cost + const costProc = spawn({ + cmd: [binary, '/cost', '--allowed-tools', ''], + cwd: probeDir, + env, + stdout: 'pipe', + stderr: 'pipe', + }); + await costProc.exited; + const costStdout = await new Response(costProc.stdout).text(); + const costStderr = await new Response(costProc.stderr).text(); + const costClean = stripAnsi(costStdout + '\n' + costStderr); + + debug.log('engine', `Claude cost output:\n${costClean}`); + + // Cost parser + const matchCost = costClean.match(/used:\s*\$([0-9.]+)/i); + const matchBudget = costClean.match(/budget:\s*\$([0-9.]+)/i); + const costVal = matchCost ? parseFloat(matchCost[1]) : 0; + const budgetVal = matchBudget ? parseFloat(matchBudget[1]) : 50; + + const percentRemaining = budgetVal > 0 ? Math.max(0, 100 - (costVal / budgetVal) * 100) : 100; + const quota: UsageQuota = { + percentRemaining, + quotaType: 'Monthly Budget', + providerId: 'claude', + resetsAt: null, + resetText: `$${costVal.toFixed(2)} / $${budgetVal.toFixed(2)}`, + }; + + return { + providerId: 'claude', + quotas: [quota], + capturedAt: new Date().toISOString(), + }; + } + + // Normal subscription usage parser + const sessionPct = extractPercent('Current session', clean); + const weeklyPct = extractPercent('Current week (all models)', clean) ?? extractPercent('Current week', clean); + const opusPct = extractPercent('Current week (Opus)', clean); + const sonnetPct = extractPercent('Current week (Sonnet)', clean) ?? extractPercent('Current week (Sonnet only)', clean); + const fablePct = extractPercent('Current week (Fable', clean); + + if (sessionPct === null) { + throw new Error('Could not find session usage percent in Claude output'); + } + + const sessionReset = extractReset('Current session', clean); + const weeklyReset = extractReset('Current week', clean); + + const quotas: UsageQuota[] = []; + + quotas.push({ + percentRemaining: sessionPct, + quotaType: 'Session', + providerId: 'claude', + resetsAt: parseResetDate(sessionReset), + resetText: sessionReset || undefined, + }); + + if (weeklyPct !== null) { + quotas.push({ + percentRemaining: weeklyPct, + quotaType: 'Weekly (All)', + providerId: 'claude', + resetsAt: parseResetDate(weeklyReset), + resetText: weeklyReset || undefined, + }); + } + + if (opusPct !== null) { + quotas.push({ + percentRemaining: opusPct, + quotaType: 'Weekly (Opus)', + providerId: 'claude', + resetsAt: parseResetDate(weeklyReset), + resetText: weeklyReset || undefined, + }); + } + + if (sonnetPct !== null) { + quotas.push({ + percentRemaining: sonnetPct, + quotaType: 'Weekly (Sonnet)', + providerId: 'claude', + resetsAt: parseResetDate(weeklyReset), + resetText: weeklyReset || undefined, + }); + } + + if (fablePct !== null) { + const fableReset = extractReset('Current week (Fable', clean) || weeklyReset; + quotas.push({ + percentRemaining: fablePct, + quotaType: 'Weekly (Fable)', + providerId: 'claude', + resetsAt: parseResetDate(fableReset), + resetText: fableReset || undefined, + }); + } + + return { + providerId: 'claude', + quotas, + capturedAt: new Date().toISOString(), + }; +} + +export const _test = { + stripAnsi, + percentFromLine, + extractPercent, + extractReset, + deduplicateResetText, + parseResetDate +}; + diff --git a/backend/engine/adapters/codex/usage.ts b/backend/engine/adapters/codex/usage.ts new file mode 100644 index 00000000..9021b0fa --- /dev/null +++ b/backend/engine/adapters/codex/usage.ts @@ -0,0 +1,136 @@ +import { spawn } from 'bun'; +import { join } from 'path'; +import { engineQueries } from '../../../database/queries'; +import { resolveBinaryWithRefresh } from '../../../utils/cli'; +import { getEngineUserConfigDir } from '$backend/utils/paths'; +import { applyAccountAuth } from './credential'; +import { debug } from '$shared/utils/logger'; +import type { UsageSnapshot, UsageQuota } from '$shared/types/unified/engine'; + +function stripAnsi(text: string): string { + return text.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, ''); +} + +function percentFromLine(line: string): number | null { + const match = line.match(/([0-9]{1,3})\s*%\s*left/i); + if (!match) return null; + return parseInt(match[1], 10); +} + +function extractPercent(labelSubstring: string, text: string): number | null { + const lines = text.split('\n'); + const label = labelSubstring.toLowerCase(); + + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(label)) { + // Search subsequent 12 lines for a percentage + const window = lines.slice(i, i + 12); + for (const candidate of window) { + const pct = percentFromLine(candidate); + if (pct !== null) { + return pct; + } + } + } + } + return null; +} + +export async function getCodexUsage(accountId?: number): Promise { + const account = accountId !== undefined + ? engineQueries.getAccount(accountId) + : engineQueries.getActiveAccountForEngine('codex'); + + if (!account) { + throw new Error('Codex account not found'); + } + + // Apply auth to disk (for chatgpt mode) + applyAccountAuth(account); + + const binary = await resolveBinaryWithRefresh('codex'); + if (!binary) { + throw new Error('Codex CLI binary not found in PATH'); + } + + const codexHome = getEngineUserConfigDir('codex'); + debug.log('engine', `Running codex with status query in CODEX_HOME: ${codexHome}`); + + // Run codex CLI in read-only and untrusted mode, passing /status to stdin + const proc = spawn({ + cmd: [binary, '-s', 'read-only', '-a', 'untrusted'], + cwd: codexHome, + env: { + ...process.env, + CODEX_HOME: codexHome + }, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + }); + + // Write /status and exit + if (proc.stdin) { + proc.stdin.write('/status\n'); + proc.stdin.write('/quit\n'); + proc.stdin.end(); + } + + await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + const output = stdout + '\n' + stderr; + const clean = stripAnsi(output); + + debug.log('engine', `Codex status output:\n${clean}`); + + // Check for errors + const lower = clean.toLowerCase(); + if (lower.includes('not logged in') || lower.includes('please log in')) { + throw new Error('Codex: not logged in or token expired'); + } + + // Parse percentages + const fiveHourPct = extractPercent('5h limit', clean); + const weeklyPct = extractPercent('Weekly limit', clean); + + const quotas: UsageQuota[] = []; + + if (fiveHourPct !== null) { + quotas.push({ + percentRemaining: fiveHourPct, + quotaType: '5h Limit', + providerId: 'codex', + resetsAt: null, + resetText: `${fiveHourPct}% remaining` + }); + } + + if (weeklyPct !== null) { + quotas.push({ + percentRemaining: weeklyPct, + quotaType: 'Weekly Limit', + providerId: 'codex', + resetsAt: null, + resetText: `${weeklyPct}% remaining` + }); + } + + if (quotas.length === 0) { + // If limits not found (common for API key mode where limits don't apply) + quotas.push({ + percentRemaining: 100, + quotaType: 'Session', + providerId: 'codex', + resetsAt: null, + resetText: 'Unlimited quota' + }); + } + + return { + providerId: 'codex', + quotas, + capturedAt: new Date().toISOString() + }; +} diff --git a/backend/engine/adapters/copilot/usage.ts b/backend/engine/adapters/copilot/usage.ts new file mode 100644 index 00000000..b567fdb2 --- /dev/null +++ b/backend/engine/adapters/copilot/usage.ts @@ -0,0 +1,89 @@ +import { engineQueries } from '../../../database/queries'; +import type { UsageSnapshot, UsageQuota } from '$shared/types/unified/engine'; +import { debug } from '$shared/utils/logger'; + +export async function getCopilotUsage(accountId?: number): Promise { + const account = accountId !== undefined + ? engineQueries.getAccount(accountId) + : engineQueries.getActiveAccountForEngine('copilot'); + + if (!account || !account.credential) { + throw new Error('Copilot account or credential not found'); + } + + const token = account.credential; + debug.log('engine', 'Fetching Copilot usage via internal API...'); + + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + 'Authorization': `Bearer ${token}`, + 'Accept': 'application/json', + 'User-Agent': 'Clopen/1.0.0' + } + }); + + if (response.status === 401) { + throw new Error('Copilot token is invalid or expired (401)'); + } + + if (!response.ok) { + throw new Error(`Copilot API request failed: HTTP ${response.status}`); + } + + const data = (await response.json()) as any; + + const plan = data.copilot_plan || 'unknown'; + const premiumInteractions = data.quota_snapshots?.premium_interactions; + + // Case 1: Unlimited premium interactions (common for some business/enterprise tiers) + if (premiumInteractions?.unlimited === true) { + return { + providerId: 'copilot', + quotas: [{ + percentRemaining: 100, + quotaType: 'Monthly', + providerId: 'copilot', + resetsAt: null, + resetText: 'Unlimited AI credits' + }], + capturedAt: new Date().toISOString(), + accountEmail: plan + }; + } + + // Case 2: Regular user with quotas + if (premiumInteractions) { + const entitlement = premiumInteractions.entitlement ?? 0; + const remaining = premiumInteractions.remaining ?? 0; + const percentRemaining = premiumInteractions.percent_remaining ?? 100; + const used = Math.max(0, entitlement - remaining); + + return { + providerId: 'copilot', + quotas: [{ + percentRemaining, + quotaType: 'Monthly', + providerId: 'copilot', + resetsAt: null, // resets monthly + resetText: `${used} / ${entitlement} AI credits` + }], + capturedAt: new Date().toISOString(), + accountEmail: plan + }; + } + + // Case 3: Fallback if no premium interactions object exists + return { + providerId: 'copilot', + quotas: [{ + percentRemaining: 100, + quotaType: 'Monthly', + providerId: 'copilot', + resetsAt: null, + resetText: 'No AI credits quota' + }], + capturedAt: new Date().toISOString(), + accountEmail: plan + }; +} diff --git a/backend/engine/adapters/opencode/usage.ts b/backend/engine/adapters/opencode/usage.ts new file mode 100644 index 00000000..2f4749d8 --- /dev/null +++ b/backend/engine/adapters/opencode/usage.ts @@ -0,0 +1,163 @@ +import { spawn } from 'bun'; +import { resolveBinaryWithRefresh } from '../../../utils/cli'; +import { debug } from '$shared/utils/logger'; +import type { UsageSnapshot, UsageQuota } from '$shared/types/unified/engine'; + +const fiveHourLimit = 12.0; +const weeklyLimit = 30.0; +const monthlyLimit = 60.0; + +function percentRemaining(used: number, limit: number): number { + if (limit <= 0) return 100; + return Math.max(0, Math.min(100, ((limit - used) / limit) * 100)); +} + +function startOfWeekUTC(date: Date): Date { + const result = new Date(date); + const day = result.getUTCDay(); // 0=Sun, 1=Mon, ..., 6=Sat + const diff = result.getUTCDate() - day + (day === 0 ? -6 : 1); // Adjust when day is sunday + result.setUTCDate(diff); + result.setUTCHours(0, 0, 0, 0); + return result; +} + +function endOfWeekUTC(date: Date): Date { + const start = startOfWeekUTC(date); + return new Date(start.getTime() + 7 * 24 * 3600 * 1000); +} + +function anchoredMonthBounds(now: Date, anchor: Date): { start: Date; end: Date } { + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), anchor.getUTCDate(), anchor.getUTCHours(), anchor.getUTCMinutes(), anchor.getUTCSeconds(), anchor.getUTCMilliseconds())); + if (start > now) { + start.setUTCMonth(start.getUTCMonth() - 1); + } + const end = new Date(start); + end.setUTCMonth(end.getUTCMonth() + 1); + return { start, end }; +} + +async function runDBQuery(opencodeBin: string, sql: string): Promise { + const proc = spawn({ + cmd: [opencodeBin, 'db', sql, '--format', 'json'], + stdout: 'pipe', + stderr: 'pipe', + }); + + await proc.exited; + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + + if (proc.exitCode !== 0) { + throw new Error(`OpenCode db command failed (exit ${proc.exitCode}): ${stderr}`); + } + + try { + return JSON.parse(stdout.trim() || '[]'); + } catch (err) { + throw new Error(`Failed to parse OpenCode db JSON output: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export async function getOpenCodeUsage(): Promise { + const binary = await resolveBinaryWithRefresh('opencode'); + if (!binary) { + throw new Error('OpenCode CLI binary not found in PATH'); + } + + const now = new Date(); + const nowMs = now.getTime(); + const fiveHourMs = nowMs - 5 * 3600 * 1000; + const weekStart = startOfWeekUTC(now); + const weekEnd = endOfWeekUTC(now); + + const subquery = ` + SELECT + CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS t, + CAST(json_extract(data, '$.cost') AS REAL) AS cost + FROM message + WHERE json_valid(data) + AND json_extract(data, '$.providerID') = 'opencode-go' + AND json_extract(data, '$.role') = 'assistant' + AND json_type(data, '$.cost') IN ('integer', 'real') + `; + + const primarySQL = ` + SELECT + COALESCE(SUM(CASE WHEN t >= ${fiveHourMs} THEN cost ELSE 0 END), 0) AS five_hour_cost, + COALESCE(SUM(CASE WHEN t >= ${weekStart.getTime()} THEN cost ELSE 0 END), 0) AS weekly_cost, + MIN(CASE WHEN t >= ${fiveHourMs} THEN t ELSE NULL END) AS five_hour_oldest_ms, + MIN(t) AS anchor_ms + FROM (${subquery}) + `; + + debug.log('engine', 'Running OpenCode primary SQL query...'); + const primaryRows = await runDBQuery(binary, primarySQL); + const primary = primaryRows[0] || { + five_hour_cost: 0, + weekly_cost: 0, + five_hour_oldest_ms: null, + anchor_ms: null, + }; + + let monthlyCost = 0; + let monthEnd = new Date(nowMs + 30 * 24 * 3600 * 1000); + + if (primary.anchor_ms !== null) { + const anchor = new Date(primary.anchor_ms); + const bounds = anchoredMonthBounds(now, anchor); + monthEnd = bounds.end; + + const monthlySQL = ` + SELECT COALESCE(SUM(cost), 0) AS monthly_cost + FROM (${subquery}) + WHERE t >= ${bounds.start.getTime()} AND t < ${bounds.end.getTime()} + `; + + debug.log('engine', 'Running OpenCode monthly SQL query...'); + const monthlyRows = await runDBQuery(binary, monthlySQL); + monthlyCost = monthlyRows[0]?.monthly_cost ?? 0; + } + + const fiveHourCost = primary.five_hour_cost; + const weeklyCost = primary.weekly_cost; + + const fiveHourRemaining = percentRemaining(fiveHourCost, fiveHourLimit); + const weeklyRemaining = percentRemaining(weeklyCost, weeklyLimit); + const monthlyRemaining = percentRemaining(monthlyCost, monthlyLimit); + + // Five hour oldest reset calculation + let fiveHourReset = new Date(nowMs + 5 * 3600 * 1000); + if (primary.five_hour_oldest_ms !== null) { + fiveHourReset = new Date(primary.five_hour_oldest_ms + 5 * 3600 * 1000); + } + + const quotas: UsageQuota[] = [ + { + percentRemaining: fiveHourRemaining, + quotaType: '5h Limit', + providerId: 'opencode-go', + resetsAt: fiveHourReset.toISOString(), + resetText: `$${fiveHourCost.toFixed(2)} / $${fiveHourLimit.toFixed(2)}`, + }, + { + percentRemaining: weeklyRemaining, + quotaType: 'Weekly Limit', + providerId: 'opencode-go', + resetsAt: weekEnd.toISOString(), + resetText: `$${weeklyCost.toFixed(2)} / $${weeklyLimit.toFixed(2)}`, + }, + { + percentRemaining: monthlyRemaining, + quotaType: 'Monthly Limit', + providerId: 'opencode-go', + resetsAt: monthEnd.toISOString(), + resetText: `$${monthlyCost.toFixed(2)} / $${monthlyLimit.toFixed(2)}`, + }, + ]; + + return { + providerId: 'opencode-go', + quotas, + capturedAt: now.toISOString(), + }; +} diff --git a/backend/ws/engine/index.ts b/backend/ws/engine/index.ts index 8a4133ec..527694cb 100644 --- a/backend/ws/engine/index.ts +++ b/backend/ws/engine/index.ts @@ -12,6 +12,7 @@ import { copilotEngineRouter } from './copilot'; import { codexEngineRouter } from './codex'; import { qwenEngineRouter } from './qwen'; import { engineRestartRouter } from './restart'; +import { engineUsageRouter } from './usage'; export const engineRouter = createRouter() .merge(claudeCodeEngineRouter) @@ -19,4 +20,5 @@ export const engineRouter = createRouter() .merge(copilotEngineRouter) .merge(codexEngineRouter) .merge(qwenEngineRouter) - .merge(engineRestartRouter); + .merge(engineRestartRouter) + .merge(engineUsageRouter); diff --git a/backend/ws/engine/usage.ts b/backend/ws/engine/usage.ts new file mode 100644 index 00000000..9430771c --- /dev/null +++ b/backend/ws/engine/usage.ts @@ -0,0 +1,81 @@ +/** + * AI Engine Usage WebSocket Router + * + * Implements the unified `engine:get-usage` router to fetch real-time + * quota and usage snapshots from the active provider adapters. + */ + +import { t } from 'elysia'; +import { createRouter } from '$shared/utils/ws-server'; +import { getClaudeUsage } from '../../engine/adapters/claude/usage'; +import { getCopilotUsage } from '../../engine/adapters/copilot/usage'; +import { getCodexUsage } from '../../engine/adapters/codex/usage'; +import { getOpenCodeUsage } from '../../engine/adapters/opencode/usage'; +import { debug } from '$shared/utils/logger'; + +const UsageQuotaSchema = t.Object({ + percentRemaining: t.Number(), + quotaType: t.String(), + providerId: t.String(), + resetsAt: t.Union([t.String(), t.Null()]), + resetText: t.Optional(t.String()), +}); + +const UsageSnapshotSchema = t.Object({ + providerId: t.String(), + quotas: t.Array(UsageQuotaSchema), + capturedAt: t.String(), + accountEmail: t.Optional(t.Union([t.String(), t.Null()])), + accountOrganization: t.Optional(t.Union([t.String(), t.Null()])), + accountTier: t.Optional(t.Union([t.String(), t.Null()])), +}); + +export const engineUsageRouter = createRouter() + .http('engine:get-usage', { + data: t.Object({ + engineType: t.String(), + accountId: t.Optional(t.Number()), + }), + response: t.Object({ + success: t.Boolean(), + snapshot: t.Union([UsageSnapshotSchema, t.Null()]), + error: t.Optional(t.String()), + }) + }, async ({ data }) => { + const { engineType, accountId } = data; + debug.log('engine', `engine:get-usage called for ${engineType} (accountId: ${accountId ?? 'active'})`); + + try { + let snapshot = null; + switch (engineType) { + case 'claude-code': + snapshot = await getClaudeUsage(accountId); + break; + case 'copilot': + snapshot = await getCopilotUsage(accountId); + break; + case 'codex': + snapshot = await getCodexUsage(accountId); + break; + case 'opencode': + snapshot = await getOpenCodeUsage(); + break; + default: + debug.warn('engine', `engine:get-usage called for unsupported engineType: ${engineType}`); + break; + } + + return { + success: true, + snapshot + }; + } catch (err: any) { + const errorMsg = err instanceof Error ? err.message : String(err); + debug.error('engine', `Failed to fetch usage for ${engineType}:`, err); + return { + success: false, + snapshot: null, + error: errorMsg + }; + } + }); diff --git a/frontend/components/chat/widgets/ContextIndicator.svelte b/frontend/components/chat/widgets/ContextIndicator.svelte index fb2642c9..58dcae15 100644 --- a/frontend/components/chat/widgets/ContextIndicator.svelte +++ b/frontend/components/chat/widgets/ContextIndicator.svelte @@ -99,13 +99,13 @@
+ + + {#if showPopover} +
showPopover = false}>
+
+
+
+ + Usage Quota +
+ +
+ + {#if usageSnapshot?.quotas && usageSnapshot.quotas.length > 0} +
+ {#each usageSnapshot.quotas as quota} + {@const qPct = quota.percentRemaining} + {@const colorClass = qPct > 40 ? 'bg-emerald-500' : qPct > 15 ? 'bg-amber-500' : 'bg-rose-500'} + {@const textClass = qPct > 40 ? 'text-emerald-600 dark:text-emerald-400' : qPct > 15 ? 'text-amber-600 dark:text-amber-400' : 'text-rose-600 dark:text-rose-400'} +
+
+ {quota.quotaType} + {Math.round(qPct)}% left +
+
+
+
+ {#if quota.resetText || quota.resetsAt} +
+ {quota.resetText || ''} + {#if quota.resetsAt} + Resets {new Date(quota.resetsAt).toLocaleString()} + {/if} +
+ {/if} +
+ {/each} +
+ {:else if usageError} +
{usageError}
+ {:else} +
No usage quota information.
+ {/if} +
+ {/if} +
+{/if} diff --git a/frontend/components/workspace/PanelHeader.svelte b/frontend/components/workspace/PanelHeader.svelte index fe522f45..c1fd91b4 100644 --- a/frontend/components/workspace/PanelHeader.svelte +++ b/frontend/components/workspace/PanelHeader.svelte @@ -21,6 +21,7 @@ import { DEVICE_VIEWPORTS } from '$frontend/utils/preview-constants'; import ContextIndicator from '$frontend/components/chat/widgets/ContextIndicator.svelte'; + import UsageIndicator from '$frontend/components/chat/widgets/UsageIndicator.svelte'; interface Props { panelId: PanelId; @@ -242,6 +243,7 @@
{#if panelId === 'chat'} + {#if sessionState.messages.length > 0 || sessionState.hasMessageHistory} {/if} diff --git a/shared/types/unified/engine.ts b/shared/types/unified/engine.ts index fc39b0f8..a89e9e7e 100644 --- a/shared/types/unified/engine.ts +++ b/shared/types/unified/engine.ts @@ -87,3 +87,21 @@ export interface QwenProviderPreset { defaultBaseUrl: string; docsUrl?: string; } + +export interface UsageQuota { + percentRemaining: number; + quotaType: string; // e.g. 'Session', 'Weekly', 'Monthly', or model name/custom limit + providerId: string; + resetsAt: string | null; // ISO 8601 string + resetText?: string; +} + +export interface UsageSnapshot { + providerId: string; + quotas: UsageQuota[]; + capturedAt: string; // ISO 8601 string + accountEmail?: string | null; + accountOrganization?: string | null; + accountTier?: string | null; +} + diff --git a/shared/types/unified/index.ts b/shared/types/unified/index.ts index 6f2186a9..e23b7d78 100644 --- a/shared/types/unified/index.ts +++ b/shared/types/unified/index.ts @@ -23,6 +23,8 @@ export type { EngineInfo, QwenProviderPresetId, QwenProviderPreset, + UsageQuota, + UsageSnapshot, } from './engine'; // ── Tools ──────────────────────────────────────────────────── From a33c9792b80c29a5f99014914f290f3e50986fc6 Mon Sep 17 00:00:00 2001 From: Agung Maulana Date: Wed, 15 Jul 2026 18:00:00 +0700 Subject: [PATCH 2/2] feat(usage-indicator): show error state and improve reactivity in quota display Adds a usageError state to display a warning icon and 'Error' label when quota fetching fails, replacing the silent failure mode. Tracks sessionState.isLoading in the effect to ensure the usage indicator updates reactively when message generation state changes. --- .../components/chat/widgets/UsageIndicator.svelte | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/frontend/components/chat/widgets/UsageIndicator.svelte b/frontend/components/chat/widgets/UsageIndicator.svelte index 87a3efe8..bbf6f4fa 100644 --- a/frontend/components/chat/widgets/UsageIndicator.svelte +++ b/frontend/components/chat/widgets/UsageIndicator.svelte @@ -14,6 +14,7 @@ import { copilotAccountsStore } from '$frontend/stores/features/copilot-accounts.svelte'; import { codexAccountsStore } from '$frontend/stores/features/codex-accounts.svelte'; import { opencodeProvidersStore } from '$frontend/stores/features/opencode-providers.svelte'; + import { sessionState } from '$frontend/stores/core/sessions.svelte'; interface Props { isMobile?: boolean; @@ -95,6 +96,8 @@ $effect(() => { const engine = chatModelState.engine; const accountId = chatModelState.accountId; + // Track message generation loading state to trigger updates + sessionState.isLoading; if (engine && hasAccounts) { fetchUsage(engine, accountId ?? undefined); } @@ -108,17 +111,23 @@ }); -{#if chatModelState.engine !== 'qwen' && hasAccounts && (remainingPercent !== undefined || isLoading)} +{#if chatModelState.engine !== 'qwen' && hasAccounts}