diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index f9bafea2..b2e4df6d 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -13,12 +13,13 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react'; +import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCloseLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiRefreshLine, RiSettings3Line, RiTerminalBoxLine, RiTimerLine, type RemixiconComponentType } from '@remixicon/react'; import { DiffIcon } from '@/components/icons/DiffIcon'; import { useUIStore, type MainTab } from '@/stores/useUIStore'; import { useUpdateStore } from '@/stores/useUpdateStore'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionStore } from '@/stores/useSessionStore'; +import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -27,8 +28,25 @@ import { useDeviceInfo } from '@/lib/device'; import { cn, getModifierLabel, hasModifier } from '@/lib/utils'; import { useDiffFileCount } from '@/components/views/DiffView'; import { McpDropdown } from '@/components/mcp/McpDropdown'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; +import { formatPercent, formatWindowLabel, QUOTA_PROVIDERS } from '@/lib/quota'; +import { UsageProgressBar } from '@/components/sections/usage/UsageProgressBar'; +import { updateDesktopSettings } from '@/lib/persistence'; +import type { UsageWindow } from '@/types'; import type { GitHubAuthStatus } from '@/lib/api/types'; +const formatTime = (timestamp: number | null) => { + if (!timestamp) return '-'; + try { + return new Date(timestamp).toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + }); + } catch { + return '-'; + } +}; + const normalize = (value: string): string => { if (!value) return ''; const replaced = value.replace(/\\/g, '/'); @@ -85,6 +103,14 @@ export const Header: React.FC = () => { const getContextUsage = useSessionStore((state) => state.getContextUsage); const currentSessionId = useSessionStore((state) => state.currentSessionId); const sessions = useSessionStore((state) => state.sessions); + const quotaResults = useQuotaStore((state) => state.results); + const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas); + const isQuotaLoading = useQuotaStore((state) => state.isLoading); + const quotaLastUpdated = useQuotaStore((state) => state.lastUpdated); + const quotaDisplayMode = useQuotaStore((state) => state.displayMode); + const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); + const loadQuotaSettings = useQuotaStore((state) => state.loadSettings); + const setQuotaDisplayMode = useQuotaStore((state) => state.setDisplayMode); const homeDirectory = useDirectoryStore((state) => state.homeDirectory); const { isMobile } = useDeviceInfo(); const diffFileCount = useDiffFileCount(); @@ -153,6 +179,41 @@ export const Header: React.FC = () => { const githubLogin = githubAuthStatus?.connected ? githubAuthStatus.user?.login : null; const githubAccounts = githubAuthStatus?.accounts ?? []; const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false); + const [isMobileRateLimitsOpen, setIsMobileRateLimitsOpen] = React.useState(false); + useQuotaAutoRefresh(); + const rateLimitGroups = React.useMemo(() => { + const groups: Array<{ + providerId: string; + providerName: string; + entries: Array<[string, UsageWindow]>; + }> = []; + + for (const provider of QUOTA_PROVIDERS) { + if (!dropdownProviderIds.includes(provider.id)) { + continue; + } + const result = quotaResults.find((entry) => entry.providerId === provider.id); + const windows = (result?.usage?.windows ?? {}) as Record; + const entries = Object.entries(windows); + if (entries.length > 0) { + groups.push({ providerId: provider.id, providerName: provider.name, entries }); + } + } + + return groups; + }, [dropdownProviderIds, quotaResults]); + const hasRateLimits = rateLimitGroups.length > 0; + React.useEffect(() => { + void loadQuotaSettings(); + }, [loadQuotaSettings]); + const handleDisplayModeChange = React.useCallback(async (mode: 'usage' | 'remaining') => { + setQuotaDisplayMode(mode); + try { + await updateDesktopSettings({ usageDisplayMode: mode }); + } catch (error) { + console.warn('Failed to update usage display mode:', error); + } + }, [setQuotaDisplayMode]); const currentSession = React.useMemo(() => { if (!currentSessionId) return null; @@ -176,22 +237,22 @@ export const Header: React.FC = () => { const payload = runtimeApis.github ? await runtimeApis.github.authActivate(accountId) : await (async () => { - const response = await fetch('/api/github/auth/activate', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - body: JSON.stringify({ accountId }), - }); - const body = (await response.json().catch(() => null)) as - | (GitHubAuthStatus & { error?: string }) - | null; - if (!response.ok || !body) { - throw new Error(body?.error || response.statusText); - } - return body; - })(); + const response = await fetch('/api/github/auth/activate', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ accountId }), + }); + const body = (await response.json().catch(() => null)) as + | (GitHubAuthStatus & { error?: string }) + | null; + if (!response.ok || !body) { + throw new Error(body?.error || response.statusText); + } + return body; + })(); setGitHubAuthStatus(payload); } catch (error) { @@ -348,7 +409,7 @@ export const Header: React.FC = () => { const node = headerRef.current; if (!node || typeof ResizeObserver === 'undefined') { - return () => {}; + return () => { }; } const observer = new ResizeObserver(() => { @@ -500,7 +561,7 @@ export const Header: React.FC = () => { = 90 ? 'text-status-error' : - contextUsage.percentage >= 75 ? 'text-status-warning' : 'text-status-success' + contextUsage.percentage >= 75 ? 'text-status-warning' : 'text-status-success' )}> {Math.min(contextUsage.percentage, 999).toFixed(1)}% @@ -563,24 +624,154 @@ export const Header: React.FC = () => {
-
- - - - - -

Command Palette ({getModifierLabel()}+K)

-
-
+
+ + + + + +

Command Palette ({getModifierLabel()}+K)

+
+
- + { + if (open && quotaResults.length === 0) { + fetchAllQuotas(); + } + }}> + + + + + + + +

Rate limits

+
+
+ +
+ + Rate limits +
+
+ + +
+ +
+
+
+ Last updated {formatTime(quotaLastUpdated)} +
+
+ {!hasRateLimits && ( + event.preventDefault()}> + No rate limits available. + + )} + {rateLimitGroups.map((group, index) => ( + + + + {group.providerName} + + {group.entries.length === 0 ? ( + event.preventDefault()} + > + No rate limits reported. + + ) : ( + group.entries.map(([label, window]) => ( + event.preventDefault()} + > + + {(() => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + return ( + <> + + {formatWindowLabel(label)} + + {formatPercent(displayPercent) === '-' ? '' : formatPercent(displayPercent)} + + + + + {window.resetAfterFormatted ?? window.resetAtFormatted ?? ''} + + + ); + })()} + + + )) + )} + {index < rateLimitGroups.length - 1 && } + + ))} +
+
+ + @@ -788,6 +979,137 @@ export const Header: React.FC = () => { + { + setIsMobileRateLimitsOpen(open); + if (open && quotaResults.length === 0) { + fetchAllQuotas(); + } + }} + > + + + + + + + +

Rate limits

+
+
+ +
+
+
+ Rate limits +
+
+ + +
+ + +
+
+
+ Last updated {formatTime(quotaLastUpdated)} +
+
+
+ {!hasRateLimits && ( +
+ No rate limits available. +
+ )} + {rateLimitGroups.map((group) => ( + +
+ + {group.providerName} +
+ {group.entries.map(([label, window]) => { + const displayPercent = quotaDisplayMode === 'remaining' + ? window.remainingPercent + : window.usedPercent; + return ( +
+
+ + {formatWindowLabel(label)} + + + {formatPercent(displayPercent)} + +
+ +
+ {window.resetAfterFormatted ?? window.resetAtFormatted ?? ''} +
+
+ ); + })} +
+ ))} +
+
+
+
+
-
{percentLabel}
+
{percentLabel === '-' ? '' : percentLabel}
- + +
+ {barLabel} +
- Resets in + Resets {resetLabel}
diff --git a/packages/ui/src/components/sections/usage/UsagePage.tsx b/packages/ui/src/components/sections/usage/UsagePage.tsx index a4409051..6e237b1f 100644 --- a/packages/ui/src/components/sections/usage/UsagePage.tsx +++ b/packages/ui/src/components/sections/usage/UsagePage.tsx @@ -3,6 +3,9 @@ import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { UsageCard } from './UsageCard'; import { QUOTA_PROVIDERS } from '@/lib/quota'; import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore'; +import { Switch } from '@/components/ui/switch'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; const formatTime = (timestamp: number | null) => { if (!timestamp) return '-'; @@ -25,6 +28,8 @@ export const UsagePage: React.FC = () => { const isLoading = useQuotaStore((state) => state.isLoading); const lastUpdated = useQuotaStore((state) => state.lastUpdated); const error = useQuotaStore((state) => state.error); + const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds); + const setDropdownProviderIds = useQuotaStore((state) => state.setDropdownProviderIds); useQuotaAutoRefresh(); @@ -33,6 +38,7 @@ export const UsagePage: React.FC = () => { void fetchAllQuotas(); }, [loadSettings, fetchAllQuotas]); + React.useEffect(() => { if (selectedProviderId) { return; @@ -46,6 +52,21 @@ export const UsagePage: React.FC = () => { const selectedResult = results.find((entry) => entry.providerId === selectedProviderId) ?? null; + const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId); + const providerName = providerMeta?.name ?? selectedProviderId ?? 'Usage'; + const usage = selectedResult?.usage; + const showInDropdown = selectedProviderId ? dropdownProviderIds.includes(selectedProviderId) : false; + const handleDropdownToggle = React.useCallback((enabled: boolean) => { + if (!selectedProviderId) { + return; + } + const next = enabled + ? Array.from(new Set([...dropdownProviderIds, selectedProviderId])) + : dropdownProviderIds.filter((id) => id !== selectedProviderId); + setDropdownProviderIds(next); + void updateDesktopSettings({ usageDropdownProviders: next }); + }, [dropdownProviderIds, selectedProviderId, setDropdownProviderIds]); + if (!selectedProviderId) { return (
@@ -54,18 +75,30 @@ export const UsagePage: React.FC = () => { ); } - const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId); - const providerName = providerMeta?.name ?? selectedProviderId; - const usage = selectedResult?.usage; - return (
-
-

{providerName} Usage

-

- {isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`} -

+
+
+
+
+ +

{providerName} Usage

+
+

+ {isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`} +

+
+
+
+ Show in dropdown + +
{!selectedResult && ( diff --git a/packages/ui/src/components/sections/usage/UsageProgressBar.tsx b/packages/ui/src/components/sections/usage/UsageProgressBar.tsx index b0d02228..a12226bf 100644 --- a/packages/ui/src/components/sections/usage/UsageProgressBar.tsx +++ b/packages/ui/src/components/sections/usage/UsageProgressBar.tsx @@ -4,12 +4,13 @@ import { clampPercent, resolveUsageTone } from '@/lib/quota'; interface UsageProgressBarProps { percent: number | null; + tonePercent?: number | null; className?: string; } -export const UsageProgressBar: React.FC = ({ percent, className }) => { +export const UsageProgressBar: React.FC = ({ percent, tonePercent, className }) => { const clamped = clampPercent(percent) ?? 0; - const tone = resolveUsageTone(percent); + const tone = resolveUsageTone(tonePercent ?? percent); const fillStyle = tone === 'critical' ? { backgroundColor: 'var(--status-error)' } diff --git a/packages/ui/src/components/sections/usage/UsageSidebar.tsx b/packages/ui/src/components/sections/usage/UsageSidebar.tsx index 0f9c0fa7..00ad2203 100644 --- a/packages/ui/src/components/sections/usage/UsageSidebar.tsx +++ b/packages/ui/src/components/sections/usage/UsageSidebar.tsx @@ -36,8 +36,10 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { const isLoading = useQuotaStore((state) => state.isLoading); const usageAutoRefresh = useQuotaStore((state) => state.autoRefresh); const usageRefreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs); + const usageDisplayMode = useQuotaStore((state) => state.displayMode); const setUsageAutoRefresh = useQuotaStore((state) => state.setAutoRefresh); const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval); + const setUsageDisplayMode = useQuotaStore((state) => state.setDisplayMode); const loadUsageSettings = useQuotaStore((state) => state.loadSettings); const { isMobile } = useDeviceInfo(); @@ -57,7 +59,7 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { void loadUsageSettings(); }, [loadUsageSettings]); - const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number }) => { + const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number; usageDisplayMode?: 'usage' | 'remaining'; usageDropdownProviders?: string[] }) => { try { await updateDesktopSettings(changes); } catch (error) { @@ -79,6 +81,17 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { void persistUsageSettings({ usageRefreshIntervalMs: next }); }, [persistUsageSettings, setUsageRefreshInterval]); + const handleUsageDisplayModeChange = React.useCallback((value: string) => { + if (value !== 'usage' && value !== 'remaining') { + return; + } + setUsageDisplayMode(value); + void persistUsageSettings({ usageDisplayMode: value }); + }, [persistUsageSettings, setUsageDisplayMode]); + + + + const bgClass = isDesktopRuntime ? 'bg-transparent' : isVSCode @@ -134,6 +147,22 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => {
+
+ Display + +
@@ -173,11 +202,11 @@ export const UsageSidebar: React.FC = ({ onItemSelect }) => { {provider.name} - {!configured && ( - Not set - )} - - + {!configured && ( + Not set + )} + + ); })} diff --git a/packages/ui/src/components/views/SettingsWindow.tsx b/packages/ui/src/components/views/SettingsWindow.tsx index 8750629a..bce10336 100644 --- a/packages/ui/src/components/views/SettingsWindow.tsx +++ b/packages/ui/src/components/views/SettingsWindow.tsx @@ -13,6 +13,7 @@ interface SettingsWindowProps { * Used for desktop and web (non-mobile) environments. */ export const SettingsWindow: React.FC = ({ open, onOpenChange }) => { + const descriptionId = React.useId(); return ( @@ -20,6 +21,7 @@ export const SettingsWindow: React.FC = ({ open, onOpenChan className="fixed inset-0 z-50 bg-black/50 backdrop-blur-md" /> = ({ open, onOpenChan 'bg-background' )} > + + OpenChamber settings window. + onOpenChange(false)} isWindowed /> diff --git a/packages/ui/src/hooks/useProviderLogo.ts b/packages/ui/src/hooks/useProviderLogo.ts index 50ed85e0..eae87d36 100644 --- a/packages/ui/src/hooks/useProviderLogo.ts +++ b/packages/ui/src/hooks/useProviderLogo.ts @@ -15,6 +15,11 @@ const localLogoModules = import.meta.glob('../assets/provider-logos/*.sv const LOCAL_PROVIDER_LOGO_MAP = new Map(); +const LOGO_ALIAS = new Map([ + ['codex', 'openai'], + ['claude', 'anthropic'], +]); + for (const [path, url] of Object.entries(localLogoModules)) { const match = path.match(/provider-logos\/([^/]+)\.svg$/i); if (match?.[1] && url) { @@ -24,20 +29,21 @@ for (const [path, url] of Object.entries(localLogoModules)) { export function useProviderLogo(providerId: string | null | undefined): UseProviderLogoReturn { const normalizedId = providerId?.toLowerCase() ?? null; - const hasLocalLogo = normalizedId ? LOCAL_PROVIDER_LOGO_MAP.has(normalizedId) : false; - const localLogoSrc = normalizedId ? LOCAL_PROVIDER_LOGO_MAP.get(normalizedId) ?? null : null; + const resolvedId = normalizedId ? LOGO_ALIAS.get(normalizedId) ?? normalizedId : null; + const hasLocalLogo = resolvedId ? LOCAL_PROVIDER_LOGO_MAP.has(resolvedId) : false; + const localLogoSrc = resolvedId ? LOCAL_PROVIDER_LOGO_MAP.get(resolvedId) ?? null : null; const [source, setSource] = useState(hasLocalLogo ? 'local' : 'remote'); useEffect(() => { setSource(hasLocalLogo ? 'local' : 'remote'); - }, [hasLocalLogo, normalizedId]); + }, [hasLocalLogo, resolvedId]); const handleError = useCallback(() => { setSource((current) => (current === 'local' && hasLocalLogo ? 'remote' : 'none')); }, [hasLocalLogo]); - if (!normalizedId) { + if (!resolvedId) { return { src: null, onError: handleError, hasLogo: false }; } @@ -51,7 +57,7 @@ export function useProviderLogo(providerId: string | null | undefined): UseProvi if (source === 'remote') { return { - src: `https://models.dev/logos/${normalizedId}.svg`, + src: `https://models.dev/logos/${resolvedId}.svg`, onError: handleError, hasLogo: true, }; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index a673d056..e17ac6c5 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -57,6 +57,8 @@ export type DesktopSettings = { notifyOnSubtasks?: boolean; usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number; + usageDisplayMode?: 'usage' | 'remaining'; + usageDropdownProviders?: string[]; autoDeleteEnabled?: boolean; autoDeleteAfterDays?: number; defaultModel?: string; // format: "provider/model" diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 5f6c066e..f1bdab45 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -357,6 +357,14 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) { result.usageRefreshIntervalMs = candidate.usageRefreshIntervalMs; } + if (candidate.usageDisplayMode === 'usage' || candidate.usageDisplayMode === 'remaining') { + result.usageDisplayMode = candidate.usageDisplayMode; + } + if (Array.isArray(candidate.usageDropdownProviders)) { + result.usageDropdownProviders = candidate.usageDropdownProviders.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0 + ); + } if ( typeof candidate.toolCallExpansion === 'string' && (candidate.toolCallExpansion === 'collapsed' diff --git a/packages/ui/src/lib/quota/providers/index.ts b/packages/ui/src/lib/quota/providers/index.ts index f649c9c1..32705133 100644 --- a/packages/ui/src/lib/quota/providers/index.ts +++ b/packages/ui/src/lib/quota/providers/index.ts @@ -6,10 +6,13 @@ export interface QuotaProviderMeta { } export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [ - { id: 'openai', name: 'OpenAI' }, + { id: 'claude', name: 'Claude' }, + { id: 'codex', name: 'Codex' }, + { id: 'github-copilot', name: 'GitHub Copilot' }, { id: 'google', name: 'Google' }, + { id: 'kimi-for-coding', name: 'Kimi for Coding' }, + { id: 'openrouter', name: 'OpenRouter' }, { id: 'zai-coding-plan', name: 'z.ai' }, - { id: 'github-copilot', name: 'GitHub Copilot' } ]; export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce>( diff --git a/packages/ui/src/lib/quota/utils.ts b/packages/ui/src/lib/quota/utils.ts index 52e04a32..31bd7f1b 100644 --- a/packages/ui/src/lib/quota/utils.ts +++ b/packages/ui/src/lib/quota/utils.ts @@ -28,7 +28,15 @@ export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'cri export const formatWindowLabel = (label: string): string => { if (label === '5h') return '5-Hour Limit'; + if (label === '7d') return '7-Day Limit'; + if (label === '7d-sonnet') return '7-Day Sonnet Limit'; + if (label === '7d-opus') return '7-Day Opus Limit'; if (label === 'weekly') return 'Weekly Limit'; + if (label === 'monthly') return 'Monthly Limit'; + if (label === 'credits') return 'Credits'; + if (label === 'premium') return 'Premium Interactions'; + if (label === 'chat') return 'Chat Requests'; + if (label === 'completions') return 'Completions'; if (label === 'premium_interactions') return 'Premium interactions'; return label; }; diff --git a/packages/ui/src/stores/useQuotaStore.ts b/packages/ui/src/stores/useQuotaStore.ts index 92ceb209..f5faed2e 100644 --- a/packages/ui/src/stores/useQuotaStore.ts +++ b/packages/ui/src/stores/useQuotaStore.ts @@ -11,6 +11,8 @@ const DEFAULT_REFRESH_INTERVAL_MS = 60000; interface QuotaSettingsState { autoRefresh: boolean; refreshIntervalMs: number; + displayMode: 'usage' | 'remaining'; + dropdownProviderIds: QuotaProviderId[]; } interface QuotaStore extends QuotaSettingsState { @@ -27,9 +29,12 @@ interface QuotaStore extends QuotaSettingsState { setSelectedProvider: (providerId: QuotaProviderId | null) => void; setAutoRefresh: (enabled: boolean) => void; setRefreshInterval: (intervalMs: number) => void; + setDisplayMode: (mode: 'usage' | 'remaining') => void; + setDropdownProviderIds: (providerIds: QuotaProviderId[]) => void; } const parseSettings = (data: Record | null): QuotaSettingsState => { + const allProviderIds = QUOTA_PROVIDERS.map((provider) => provider.id); const autoRefresh = typeof data?.usageAutoRefresh === 'boolean' ? data.usageAutoRefresh : false; @@ -38,7 +43,17 @@ const parseSettings = (data: Record | null): QuotaSettingsState ? Math.max(30000, Math.min(300000, Math.round(data.usageRefreshIntervalMs))) : DEFAULT_REFRESH_INTERVAL_MS; - return { autoRefresh, refreshIntervalMs }; + const displayMode = data?.usageDisplayMode === 'remaining' ? 'remaining' : 'usage'; + const rawDropdownProviders = Array.isArray(data?.usageDropdownProviders) + ? data?.usageDropdownProviders + : null; + const dropdownProviderIds = rawDropdownProviders + ? rawDropdownProviders.filter((entry): entry is QuotaProviderId => + typeof entry === 'string' && allProviderIds.includes(entry as QuotaProviderId) + ) + : allProviderIds; + + return { autoRefresh, refreshIntervalMs, displayMode, dropdownProviderIds }; }; const loadSettingsFromRuntime = async (): Promise => { @@ -69,7 +84,12 @@ const loadSettingsFromRuntime = async (): Promise => { } } - return { autoRefresh: false, refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS }; + return { + autoRefresh: false, + refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS, + displayMode: 'usage', + dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id) + }; }; export const useQuotaStore = create()( @@ -83,6 +103,8 @@ export const useQuotaStore = create()( error: null, autoRefresh: false, refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS, + displayMode: 'usage', + dropdownProviderIds: QUOTA_PROVIDERS.map((provider) => provider.id), loadSettings: async () => { try { @@ -155,7 +177,9 @@ export const useQuotaStore = create()( setRefreshInterval: (intervalMs) => { const clamped = Math.max(30000, Math.min(300000, Math.round(intervalMs))); set({ refreshIntervalMs: clamped }); - } + }, + setDisplayMode: (mode) => set({ displayMode: mode }), + setDropdownProviderIds: (providerIds) => set({ dropdownProviderIds: providerIds }) }), { name: 'quota-store' } ) diff --git a/packages/ui/src/types/quota.ts b/packages/ui/src/types/quota.ts index b58ebfcf..59e1210d 100644 --- a/packages/ui/src/types/quota.ts +++ b/packages/ui/src/types/quota.ts @@ -1,4 +1,13 @@ -export type QuotaProviderId = 'openai' | 'google' | 'zai-coding-plan' | 'github-copilot'; +export type QuotaProviderId = + | 'openai' + | 'codex' + | 'claude' + | 'github-copilot' + | 'github-copilot-addon' + | 'google' + | 'kimi-for-coding' + | 'openrouter' + | 'zai-coding-plan'; export interface UsageWindow { usedPercent: number | null; @@ -8,6 +17,7 @@ export interface UsageWindow { resetAt: number | null; resetAtFormatted: string | null; resetAfterFormatted: string | null; + valueLabel?: string | null; } export interface UsageWindows { diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index 000b84ff..84cad715 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -13,6 +13,7 @@ type UsageWindow = { resetAt: number | null; resetAtFormatted: string | null; resetAfterFormatted: string | null; + valueLabel?: string | null; }; type ProviderUsage = { @@ -33,6 +34,10 @@ type OpenAiUsagePayload = { reset_at?: number; }; }; + credits?: { + balance?: number | string; + unlimited?: boolean; + }; }; type GoogleModelsPayload = { @@ -72,6 +77,7 @@ const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json'); + const ANTIGRAVITY_ACCOUNTS_PATHS = [ path.join(OPENCODE_CONFIG_DIR, 'antigravity-accounts.json'), path.join(OPENCODE_DATA_DIR, 'antigravity-accounts.json'), @@ -152,6 +158,29 @@ const normalizeAuthEntry = (entry: AuthEntry | null) => { return null; }; +const toNumber = (value: unknown): number | null => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const toTimestamp = (value: unknown): number | null => { + if (!value) return null; + if (typeof value === 'number') { + return value < 1_000_000_000_000 ? value * 1000 : value; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +}; + const formatResetTime = (timestamp: number) => { try { const resetDate = new Date(timestamp); @@ -185,7 +214,7 @@ const calculateResetAfterSeconds = (resetAt: number | null) => { return delta < 0 ? 0 : delta; }; -const toUsageWindow = (data: { usedPercent: number | null; windowSeconds: number | null; resetAt: number | null }) => { +const toUsageWindow = (data: { usedPercent: number | null; windowSeconds: number | null; resetAt: number | null; valueLabel?: string | null }) => { const resetAfterSeconds = calculateResetAfterSeconds(data.resetAt); const resetFormatted = data.resetAt ? formatResetTime(data.resetAt) : null; return { @@ -196,6 +225,7 @@ const toUsageWindow = (data: { usedPercent: number | null; windowSeconds: number resetAt: data.resetAt, resetAtFormatted: resetFormatted, resetAfterFormatted: resetFormatted, + ...(data.valueLabel ? { valueLabel: data.valueLabel } : {}), } satisfies UsageWindow; }; @@ -216,13 +246,39 @@ const buildResult = (data: { fetchedAt: Date.now(), }); +const formatMoney = (value: number | null) => { + if (value === null || !Number.isFinite(value)) return null; + return value.toFixed(2); +}; + +const durationToLabel = (duration?: number, unit?: string) => { + if (!duration || !unit) return 'limit'; + if (unit === 'TIME_UNIT_MINUTE') return `${duration}m`; + if (unit === 'TIME_UNIT_HOUR') return `${duration}h`; + if (unit === 'TIME_UNIT_DAY') return `${duration}d`; + return 'limit'; +}; + +const durationToSeconds = (duration?: number, unit?: string) => { + if (!duration || !unit) return null; + if (unit === 'TIME_UNIT_MINUTE') return duration * 60; + if (unit === 'TIME_UNIT_HOUR') return duration * 3600; + if (unit === 'TIME_UNIT_DAY') return duration * 86400; + return null; +}; + export const listConfiguredQuotaProviders = () => { const auth = readAuthFile(); const configured = new Set(); + const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); + if (anthropicAuth && ((anthropicAuth as Record).access || (anthropicAuth as Record).token)) { + configured.add('claude'); + } + const openaiAuth = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])); if (openaiAuth && ((openaiAuth as Record).access || (openaiAuth as Record).token)) { - configured.add('openai'); + configured.add('codex'); } const googleAuth = normalizeAuthEntry(getAuthEntry(auth, ['google', 'antigravity'])); @@ -235,9 +291,20 @@ export const listConfiguredQuotaProviders = () => { configured.add('zai-coding-plan'); } - const githubCopilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])); - if (githubCopilotAuth && ((githubCopilotAuth as Record).access || (githubCopilotAuth as Record).token)) { + const kimiAuth = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])); + if (kimiAuth && ((kimiAuth as Record).key || (kimiAuth as Record).token)) { + configured.add('kimi-for-coding'); + } + + const openrouterAuth = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])); + if (openrouterAuth && ((openrouterAuth as Record).key || (openrouterAuth as Record).token)) { + configured.add('openrouter'); + } + + const copilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); + if (copilotAuth && ((copilotAuth as Record).access || (copilotAuth as Record).token)) { configured.add('github-copilot'); + configured.add('github-copilot-addon'); } for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { @@ -252,15 +319,16 @@ export const listConfiguredQuotaProviders = () => { return Array.from(configured); }; -export const fetchOpenaiQuota = async (): Promise => { +export const fetchCodexQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])) as Record | null; const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); + const accountId = entry?.accountId as string | undefined; if (!accessToken) { return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', + providerId: 'codex', + providerName: 'Codex', ok: false, configured: false, error: 'Not configured', @@ -273,13 +341,14 @@ export const fetchOpenaiQuota = async (): Promise => { headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', + ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}), }, }); if (!response.ok) { return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', + providerId: 'codex', + providerName: 'Codex', ok: false, configured: true, error: `API error: ${response.status}`, @@ -289,34 +358,50 @@ export const fetchOpenaiQuota = async (): Promise => { const payload = await response.json() as OpenAiUsagePayload; const primary = payload?.rate_limit?.primary_window ?? null; const secondary = payload?.rate_limit?.secondary_window ?? null; + const credits = payload?.credits ?? null; const windows: Record = {}; if (primary) { windows['5h'] = toUsageWindow({ - usedPercent: typeof primary.used_percent === 'number' ? primary.used_percent : null, - windowSeconds: typeof primary.limit_window_seconds === 'number' ? primary.limit_window_seconds : null, - resetAt: primary.reset_at ? primary.reset_at * 1000 : null, + usedPercent: toNumber(primary.used_percent), + windowSeconds: toNumber(primary.limit_window_seconds), + resetAt: toTimestamp(primary.reset_at), }); } if (secondary) { windows['weekly'] = toUsageWindow({ - usedPercent: typeof secondary.used_percent === 'number' ? secondary.used_percent : null, - windowSeconds: typeof secondary.limit_window_seconds === 'number' ? secondary.limit_window_seconds : null, - resetAt: secondary.reset_at ? secondary.reset_at * 1000 : null, + usedPercent: toNumber(secondary.used_percent), + windowSeconds: toNumber(secondary.limit_window_seconds), + resetAt: toTimestamp(secondary.reset_at), + }); + } + if (credits) { + const balance = toNumber(credits.balance); + const unlimited = Boolean(credits.unlimited); + const valueLabel = unlimited + ? 'Unlimited' + : balance !== null + ? `$${formatMoney(balance)} remaining` + : null; + windows.credits = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel, }); } return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', + providerId: 'codex', + providerName: 'Codex', ok: true, configured: true, usage: { windows }, }); } catch (error) { return buildResult({ - providerId: 'openai', - providerName: 'OpenAI', + providerId: 'codex', + providerName: 'Codex', ok: false, configured: true, error: error instanceof Error ? error.message : 'Request failed', @@ -502,6 +587,398 @@ export const fetchGoogleQuota = async (): Promise => { }); }; +export const fetchClaudeQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])) as Record | null; + const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); + + if (!accessToken) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20', + }, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as Record; + const windows: Record = {}; + const fiveHour = (payload as Record).five_hour as Record | undefined; + const sevenDay = (payload as Record).seven_day as Record | undefined; + const sevenDaySonnet = (payload as Record).seven_day_sonnet as Record | undefined; + const sevenDayOpus = (payload as Record).seven_day_opus as Record | undefined; + + if (fiveHour) { + windows['5h'] = toUsageWindow({ + usedPercent: toNumber(fiveHour.utilization), + windowSeconds: null, + resetAt: toTimestamp(fiveHour.resets_at), + }); + } + if (sevenDay) { + windows['7d'] = toUsageWindow({ + usedPercent: toNumber(sevenDay.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDay.resets_at), + }); + } + if (sevenDaySonnet) { + windows['7d-sonnet'] = toUsageWindow({ + usedPercent: toNumber(sevenDaySonnet.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDaySonnet.resets_at), + }); + } + if (sevenDayOpus) { + windows['7d-opus'] = toUsageWindow({ + usedPercent: toNumber(sevenDayOpus.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDayOpus.resets_at), + }); + } + + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; + +const buildCopilotWindows = (payload: Record) => { + const quota = (payload.quota_snapshots as Record) ?? {}; + const resetAt = toTimestamp(payload.quota_reset_date); + const windows: Record = {}; + + const addWindow = (label: string, snapshot?: Record) => { + if (!snapshot) return; + const entitlement = toNumber(snapshot.entitlement); + const remaining = toNumber(snapshot.remaining); + const usedPercent = entitlement && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100)) + : null; + const valueLabel = entitlement !== null && remaining !== null + ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` + : null; + windows[label] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + valueLabel, + }); + }; + + addWindow('chat', quota.chat as Record | undefined); + addWindow('completions', quota.completions as Record | undefined); + addWindow('premium', quota.premium_interactions as Record | undefined); + + return windows; +}; + +export const fetchCopilotQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])) as Record | null; + const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); + + if (!accessToken) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + 'Editor-Version': 'vscode/1.96.2', + 'X-Github-Api-Version': '2025-04-01', + }, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as Record; + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: true, + configured: true, + usage: { windows: buildCopilotWindows(payload) }, + }); + } catch (error) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; + +export const fetchCopilotAddonQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])) as Record | null; + const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); + + if (!accessToken) { + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + 'Editor-Version': 'vscode/1.96.2', + 'X-Github-Api-Version': '2025-04-01', + }, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as Record; + const windows = buildCopilotWindows(payload); + const premium = windows.premium ? { premium: windows.premium } : windows; + + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: true, + configured: true, + usage: { windows: premium }, + }); + } catch (error) { + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; + +export const fetchKimiQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + const response = await fetch('https://api.kimi.com/coding/v1/usages', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as Record; + const windows: Record = {}; + const usage = payload.usage as Record | undefined; + if (usage) { + const limit = toNumber(usage.limit); + const remaining = toNumber(usage.remaining); + const usedPercent = limit && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100)) + : null; + windows.weekly = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: toTimestamp(usage.resetTime), + }); + } + + const limits = Array.isArray(payload.limits) ? payload.limits : []; + for (const limit of limits) { + const window = (limit as Record)?.window as Record | undefined; + const detail = (limit as Record)?.detail as Record | undefined; + const rawLabel = durationToLabel(window?.duration as number | undefined, window?.timeUnit as string | undefined); + const windowSeconds = durationToSeconds(window?.duration as number | undefined, window?.timeUnit as string | undefined); + const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel; + const total = toNumber(detail?.limit); + const remaining = toNumber(detail?.remaining); + const usedPercent = total && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / total) * 100)) + : null; + windows[label] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt: toTimestamp(detail?.resetTime), + }); + } + + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: true, + configured: true, + usage: { windows }, + }); + } catch (error) { + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; + +export const fetchOpenRouterQuota = async (): Promise => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])) as Record | null; + const apiKey = (entry?.key as string | undefined) ?? (entry?.token as string | undefined); + + if (!apiKey) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: false, + error: 'Not configured', + }); + } + + try { + const response = await fetch('https://openrouter.ai/api/v1/credits', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: `API error: ${response.status}`, + }); + } + + const payload = await response.json() as Record; + const credits = payload.data as Record | undefined; + const totalCredits = toNumber(credits?.total_credits); + const totalUsage = toNumber(credits?.total_usage); + const remaining = totalCredits !== null && totalUsage !== null + ? Math.max(0, totalCredits - totalUsage) + : null; + const usedPercent = totalCredits && totalUsage !== null + ? Math.max(0, Math.min(100, (totalUsage / totalCredits) * 100)) + : null; + const valueLabel = remaining !== null ? `$${formatMoney(remaining)} remaining` : null; + + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: true, + configured: true, + usage: { + windows: { + credits: toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: null, + valueLabel, + }), + }, + }, + }); + } catch (error) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed', + }); + } +}; + + const normalizeTimestamp = (value: unknown) => { if (typeof value !== 'number') return null; return value < 1_000_000_000_000 ? value * 1000 : value; @@ -595,133 +1072,24 @@ export const fetchZaiQuota = async (): Promise => { } }; -type CopilotSnapshot = { - unlimited?: boolean; - percent_remaining?: number; - entitlement?: number; - remaining?: number; - quota_remaining?: number; -}; - -type CopilotPayload = { - quota_snapshots?: { - premium_interactions?: CopilotSnapshot; - }; - quota_reset_date_utc?: string; - quota_reset_date?: string; -}; - -export const fetchGitHubCopilotQuota = async (): Promise => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])) as Record | null; - const accessToken = (entry?.access as string | undefined) ?? (entry?.token as string | undefined); - - if (!accessToken) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: false, - error: 'Not configured', - }); - } - - try { - const response = await fetch('https://api.github.com/copilot_internal/user', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'OpenChamber', - }, - }); - - if (!response.ok) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: true, - error: `API error: ${response.status}`, - }); - } - - const payload = await response.json() as CopilotPayload; - const snapshots = payload?.quota_snapshots ?? {}; - const premiumInteractions = snapshots?.premium_interactions ?? null; - - // Parse reset date - let resetAt: number | null = null; - const resetDateUtc = payload?.quota_reset_date_utc; - const resetDate = payload?.quota_reset_date; - - if (resetDateUtc) { - resetAt = new Date(resetDateUtc).getTime(); - } else if (resetDate) { - // Use the date as UTC midnight - resetAt = new Date(`${resetDate}T00:00:00Z`).getTime(); - } - - const windows: Record = {}; - - if (premiumInteractions) { - let usedPercent: number | null = null; - - if (premiumInteractions.unlimited === true) { - usedPercent = null; - } else if (typeof premiumInteractions.percent_remaining === 'number') { - usedPercent = 100 - premiumInteractions.percent_remaining; - } else if ( - typeof premiumInteractions.entitlement === 'number' && - premiumInteractions.entitlement > 0 - ) { - const remaining = - typeof premiumInteractions.remaining === 'number' - ? premiumInteractions.remaining - : typeof premiumInteractions.quota_remaining === 'number' - ? premiumInteractions.quota_remaining - : null; - - if (remaining !== null) { - usedPercent = ((premiumInteractions.entitlement - remaining) / premiumInteractions.entitlement) * 100; - } - } - - windows['premium_interactions'] = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt, - }); - } - - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: true, - configured: true, - usage: { windows }, - }); - } catch (error) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed', - }); - } -}; - export const fetchQuotaForProvider = async (providerId: string): Promise => { switch (providerId) { - case 'openai': - return fetchOpenaiQuota(); + case 'claude': + return fetchClaudeQuota(); + case 'codex': + return fetchCodexQuota(); + case 'github-copilot': + return fetchCopilotQuota(); + case 'github-copilot-addon': + return fetchCopilotAddonQuota(); case 'google': return fetchGoogleQuota(); + case 'kimi-for-coding': + return fetchKimiQuota(); + case 'openrouter': + return fetchOpenRouterQuota(); case 'zai-coding-plan': return fetchZaiQuota(); - case 'github-copilot': - return fetchGitHubCopilotQuota(); default: return buildResult({ providerId, diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 1757eca0..c501dc02 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -984,6 +984,9 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) { result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs))); } + if (Array.isArray(candidate.usageDropdownProviders)) { + result.usageDropdownProviders = normalizeStringArray(candidate.usageDropdownProviders); + } if (typeof candidate.autoDeleteEnabled === 'boolean') { result.autoDeleteEnabled = candidate.autoDeleteEnabled; } diff --git a/packages/web/server/lib/quota-providers.js b/packages/web/server/lib/quota-providers.js index f341a984..f9b35ed1 100644 --- a/packages/web/server/lib/quota-providers.js +++ b/packages/web/server/lib/quota-providers.js @@ -6,6 +6,7 @@ import { readAuthFile } from './opencode-auth.js'; const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); + const ANTIGRAVITY_ACCOUNTS_PATHS = [ path.join(OPENCODE_CONFIG_DIR, 'antigravity-accounts.json'), path.join(OPENCODE_DATA_DIR, 'antigravity-accounts.json') @@ -46,6 +47,29 @@ const normalizeAuthEntry = (entry) => { return null; }; +const toNumber = (value) => { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string') { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +}; + +const toTimestamp = (value) => { + if (!value) return null; + if (typeof value === 'number') { + return value < 1_000_000_000_000 ? value * 1000 : value; + } + if (typeof value === 'string') { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +}; + const formatResetTime = (timestamp) => { try { const resetDate = new Date(timestamp); @@ -79,7 +103,7 @@ const calculateResetAfterSeconds = (resetAt) => { return delta < 0 ? 0 : delta; }; -const toUsageWindow = ({ usedPercent, windowSeconds, resetAt }) => { +const toUsageWindow = ({ usedPercent, windowSeconds, resetAt, valueLabel }) => { const resetAfterSeconds = calculateResetAfterSeconds(resetAt); const resetFormatted = resetAt ? formatResetTime(resetAt) : null; return { @@ -89,7 +113,8 @@ const toUsageWindow = ({ usedPercent, windowSeconds, resetAt }) => { resetAfterSeconds, resetAt, resetAtFormatted: resetFormatted, - resetAfterFormatted: resetFormatted + resetAfterFormatted: resetFormatted, + ...(valueLabel ? { valueLabel } : {}) }; }; @@ -103,13 +128,35 @@ const buildResult = ({ providerId, providerName, ok, configured, usage, error }) fetchedAt: Date.now() }); +const durationToLabel = (duration, unit) => { + if (!duration || !unit) return 'limit'; + if (unit === 'TIME_UNIT_MINUTE') return `${duration}m`; + if (unit === 'TIME_UNIT_HOUR') return `${duration}h`; + if (unit === 'TIME_UNIT_DAY') return `${duration}d`; + return 'limit'; +}; + +const durationToSeconds = (duration, unit) => { + if (!duration || !unit) return null; + if (unit === 'TIME_UNIT_MINUTE') return duration * 60; + if (unit === 'TIME_UNIT_HOUR') return duration * 3600; + if (unit === 'TIME_UNIT_DAY') return duration * 86400; + return null; +}; + + export const listConfiguredQuotaProviders = () => { const auth = readAuthFile(); const configured = new Set(); + const anthropicAuth = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); + if (anthropicAuth?.access || anthropicAuth?.token) { + configured.add('claude'); + } + const openaiAuth = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])); if (openaiAuth?.access || openaiAuth?.token) { - configured.add('openai'); + configured.add('codex'); } const googleAuth = normalizeAuthEntry(getAuthEntry(auth, ['google', 'antigravity'])); @@ -122,9 +169,20 @@ export const listConfiguredQuotaProviders = () => { configured.add('zai-coding-plan'); } - const githubCopilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])); - if (githubCopilotAuth?.access || githubCopilotAuth?.token) { + const kimiAuth = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])); + if (kimiAuth?.key || kimiAuth?.token) { + configured.add('kimi-for-coding'); + } + + const openrouterAuth = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])); + if (openrouterAuth?.key || openrouterAuth?.token) { + configured.add('openrouter'); + } + + const copilotAuth = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); + if (copilotAuth?.access || copilotAuth?.token) { configured.add('github-copilot'); + configured.add('github-copilot-addon'); } for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) { @@ -397,6 +455,493 @@ export const fetchGoogleQuota = async () => { }); }; +const formatMoney = (value) => { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + return value.toFixed(2); +}; + +export const fetchClaudeQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.anthropic.com/api/oauth/usage', { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'anthropic-beta': 'oauth-2025-04-20' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = {}; + const fiveHour = payload?.five_hour ?? null; + const sevenDay = payload?.seven_day ?? null; + const sevenDaySonnet = payload?.seven_day_sonnet ?? null; + const sevenDayOpus = payload?.seven_day_opus ?? null; + + if (fiveHour) { + windows['5h'] = toUsageWindow({ + usedPercent: toNumber(fiveHour.utilization), + windowSeconds: null, + resetAt: toTimestamp(fiveHour.resets_at) + }); + } + if (sevenDay) { + windows['7d'] = toUsageWindow({ + usedPercent: toNumber(sevenDay.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDay.resets_at) + }); + } + if (sevenDaySonnet) { + windows['7d-sonnet'] = toUsageWindow({ + usedPercent: toNumber(sevenDaySonnet.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDaySonnet.resets_at) + }); + } + if (sevenDayOpus) { + windows['7d-opus'] = toUsageWindow({ + usedPercent: toNumber(sevenDayOpus.utilization), + windowSeconds: null, + resetAt: toTimestamp(sevenDayOpus.resets_at) + }); + } + + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +export const fetchCodexQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt'])); + const accessToken = entry?.access ?? entry?.token; + const accountId = entry?.accountId; + + if (!accessToken) { + return buildResult({ + providerId: 'codex', + providerName: 'Codex', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const headers = { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + ...(accountId ? { 'ChatGPT-Account-Id': accountId } : {}) + }; + const response = await fetch('https://chatgpt.com/backend-api/wham/usage', { + method: 'GET', + headers + }); + + if (!response.ok) { + return buildResult({ + providerId: 'codex', + providerName: 'Codex', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const primary = payload?.rate_limit?.primary_window ?? null; + const secondary = payload?.rate_limit?.secondary_window ?? null; + const credits = payload?.credits ?? null; + + const windows = {}; + if (primary) { + windows['5h'] = toUsageWindow({ + usedPercent: toNumber(primary.used_percent), + windowSeconds: toNumber(primary.limit_window_seconds), + resetAt: toTimestamp(primary.reset_at) + }); + } + if (secondary) { + windows['weekly'] = toUsageWindow({ + usedPercent: toNumber(secondary.used_percent), + windowSeconds: toNumber(secondary.limit_window_seconds), + resetAt: toTimestamp(secondary.reset_at) + }); + } + if (credits) { + const balance = toNumber(credits.balance); + const unlimited = Boolean(credits.unlimited); + const label = unlimited + ? 'Unlimited' + : balance !== null + ? `$${formatMoney(balance)} remaining` + : null; + windows.credits = toUsageWindow({ + usedPercent: null, + windowSeconds: null, + resetAt: null, + valueLabel: label + }); + } + + return buildResult({ + providerId: 'codex', + providerName: 'Codex', + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId: 'codex', + providerName: 'Codex', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +const buildCopilotWindows = (payload) => { + const quota = payload?.quota_snapshots ?? {}; + const resetAt = toTimestamp(payload?.quota_reset_date); + const windows = {}; + + const addWindow = (label, snapshot) => { + if (!snapshot) return; + const entitlement = toNumber(snapshot.entitlement); + const remaining = toNumber(snapshot.remaining); + const usedPercent = entitlement && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / entitlement) * 100)) + : null; + const valueLabel = entitlement !== null && remaining !== null + ? `${remaining.toFixed(0)} / ${entitlement.toFixed(0)} left` + : null; + windows[label] = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt, + valueLabel + }); + }; + + addWindow('chat', quota.chat); + addWindow('completions', quota.completions); + addWindow('premium', quota.premium_interactions); + + return windows; +}; + +export const fetchCopilotQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + 'Editor-Version': 'vscode/1.96.2', + 'X-Github-Api-Version': '2025-04-01' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: true, + configured: true, + usage: { windows: buildCopilotWindows(payload) } + }); + } catch (error) { + return buildResult({ + providerId: 'github-copilot', + providerName: 'GitHub Copilot', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +export const fetchCopilotAddonQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot', 'copilot'])); + const accessToken = entry?.access ?? entry?.token; + + if (!accessToken) { + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.github.com/copilot_internal/user', { + method: 'GET', + headers: { + Authorization: `token ${accessToken}`, + Accept: 'application/json', + 'Editor-Version': 'vscode/1.96.2', + 'X-Github-Api-Version': '2025-04-01' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = buildCopilotWindows(payload); + const premium = windows.premium ? { premium: windows.premium } : windows; + + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: true, + configured: true, + usage: { windows: premium } + }); + } catch (error) { + return buildResult({ + providerId: 'github-copilot-addon', + providerName: 'GitHub Copilot Add-on', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +export const fetchKimiQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['kimi-for-coding', 'kimi'])); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://api.kimi.com/coding/v1/usages', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const windows = {}; + const usage = payload?.usage ?? null; + if (usage) { + const limit = toNumber(usage.limit); + const remaining = toNumber(usage.remaining); + const usedPercent = limit && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / limit) * 100)) + : null; + windows.weekly = toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: toTimestamp(usage.resetTime) + }); + } + + const limits = Array.isArray(payload?.limits) ? payload.limits : []; + for (const limit of limits) { + const window = limit?.window; + const detail = limit?.detail; + const rawLabel = durationToLabel(window?.duration, window?.timeUnit); + const windowSeconds = durationToSeconds(window?.duration, window?.timeUnit); + const label = windowSeconds === 5 * 60 * 60 ? `Rate Limit (${rawLabel})` : rawLabel; + const total = toNumber(detail?.limit); + const remaining = toNumber(detail?.remaining); + const usedPercent = total && remaining !== null + ? Math.max(0, Math.min(100, 100 - (remaining / total) * 100)) + : null; + windows[label] = toUsageWindow({ + usedPercent, + windowSeconds, + resetAt: toTimestamp(detail?.resetTime) + }); + } + + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId: 'kimi-for-coding', + providerName: 'Kimi for Coding', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + +export const fetchOpenRouterQuota = async () => { + const auth = readAuthFile(); + const entry = normalizeAuthEntry(getAuthEntry(auth, ['openrouter'])); + const apiKey = entry?.key ?? entry?.token; + + if (!apiKey) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: false, + error: 'Not configured' + }); + } + + try { + const response = await fetch('https://openrouter.ai/api/v1/credits', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: `API error: ${response.status}` + }); + } + + const payload = await response.json(); + const credits = payload?.data ?? {}; + const totalCredits = toNumber(credits.total_credits); + const totalUsage = toNumber(credits.total_usage); + const remaining = totalCredits !== null && totalUsage !== null + ? Math.max(0, totalCredits - totalUsage) + : null; + const usedPercent = totalCredits && totalUsage !== null + ? Math.max(0, Math.min(100, (totalUsage / totalCredits) * 100)) + : null; + const valueLabel = remaining !== null ? `$${formatMoney(remaining)} remaining` : null; + + const windows = { + credits: toUsageWindow({ + usedPercent, + windowSeconds: null, + resetAt: null, + valueLabel + }) + }; + + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: true, + configured: true, + usage: { windows } + }); + } catch (error) { + return buildResult({ + providerId: 'openrouter', + providerName: 'OpenRouter', + ok: false, + configured: true, + error: error instanceof Error ? error.message : 'Request failed' + }); + } +}; + const normalizeTimestamp = (value) => { if (typeof value !== 'number') return null; return value < 1_000_000_000_000 ? value * 1000 : value; @@ -492,117 +1037,24 @@ export const fetchZaiQuota = async () => { } }; -export const fetchGitHubCopilotQuota = async () => { - const auth = readAuthFile(); - const entry = normalizeAuthEntry(getAuthEntry(auth, ['github-copilot'])); - const accessToken = entry?.access ?? entry?.token; - - if (!accessToken) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: false, - error: 'Not configured' - }); - } - - try { - const response = await fetch('https://api.github.com/copilot_internal/user', { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/vnd.github+json', - 'User-Agent': 'OpenChamber' - } - }); - - if (!response.ok) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: true, - error: `API error: ${response.status}` - }); - } - - const payload = await response.json(); - const snapshots = payload?.quota_snapshots ?? {}; - const premiumInteractions = snapshots?.premium_interactions ?? null; - - // Parse reset date - let resetAt = null; - const resetDateUtc = payload?.quota_reset_date_utc; - const resetDate = payload?.quota_reset_date; - - if (resetDateUtc) { - resetAt = new Date(resetDateUtc).getTime(); - } else if (resetDate) { - // Use the date as UTC midnight - resetAt = new Date(`${resetDate}T00:00:00Z`).getTime(); - } - - const windows = {}; - - if (premiumInteractions) { - let usedPercent = null; - - if (premiumInteractions.unlimited === true) { - usedPercent = null; - } else if (typeof premiumInteractions.percent_remaining === 'number') { - usedPercent = 100 - premiumInteractions.percent_remaining; - } else if ( - typeof premiumInteractions.entitlement === 'number' && - premiumInteractions.entitlement > 0 - ) { - const remaining = - typeof premiumInteractions.remaining === 'number' - ? premiumInteractions.remaining - : typeof premiumInteractions.quota_remaining === 'number' - ? premiumInteractions.quota_remaining - : null; - - if (remaining !== null) { - usedPercent = ((premiumInteractions.entitlement - remaining) / premiumInteractions.entitlement) * 100; - } - } - - windows['premium_interactions'] = toUsageWindow({ - usedPercent, - windowSeconds: null, - resetAt - }); - } - - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: true, - configured: true, - usage: { windows } - }); - } catch (error) { - return buildResult({ - providerId: 'github-copilot', - providerName: 'GitHub Copilot', - ok: false, - configured: true, - error: error instanceof Error ? error.message : 'Request failed' - }); - } -}; - export const fetchQuotaForProvider = async (providerId) => { switch (providerId) { - case 'openai': - return fetchOpenaiQuota(); + case 'claude': + return fetchClaudeQuota(); + case 'codex': + return fetchCodexQuota(); + case 'github-copilot': + return fetchCopilotQuota(); + case 'github-copilot-addon': + return fetchCopilotAddonQuota(); case 'google': return fetchGoogleQuota(); + case 'kimi-for-coding': + return fetchKimiQuota(); + case 'openrouter': + return fetchOpenRouterQuota(); case 'zai-coding-plan': return fetchZaiQuota(); - case 'github-copilot': - return fetchGitHubCopilotQuota(); default: return buildResult({ providerId,