feat: Multi-provider Usage Dashboard & Quota Monitoring (#259)
* feat(openchamber): persist usage auto-refresh settings Enable a switch to toggle automatic usage refresh Provide input to configure refresh interval in milliseconds Persist changes to desktop settings and server API when changed * feat: add UsageCard component Add a new UsageCard component to display a usage window with title, optional subtitle, and a progress bar Show current usage percentage and a reset time label for the window Render a formatted window label and a compact subtitle for concise UI * feat(usage): add UsagePage UI for quota usage Add a dedicated UsagePage with provider-based usage details Show last updated time and auto-refresh status Handle empty, not configured, and error states with informative banners * feat(usage): add UsageProgressBar component Introduce a new UsageProgressBar component to visualize quota usage Display a gradient fill that changes with critical, warn, or normal tones Expose accessible progress attributes for screen readers * feat(usage): add UsageSidebar component Display quotas for all providers in a scrollable sidebar Refresh quotas with a button and loading indicator Colorize provider rows based on usage status and runtime context * feat: add usage section to Settings Add a new Usage item to the Settings sidebar for desktop and mobile Render UsagePage when the Usage tab is selected in Settings Wire up new UsageSidebar and UsagePage components under usage * feat: add Usage section to sidebar Add new Usage section in the sidebar for API quota monitoring. Display a bar chart icon and description for the Usage item. Monitor and display API quota usage across providers. * feat(desktop): add usage auto-refresh settings Enable automatic refresh for usage data with new settings Store refresh interval in milliseconds for usage updates * feat(persistence): support usageAutoRefresh and usageRefreshIntervalMs Persist usageAutoRefresh in desktop settings Persist usageRefreshIntervalMs in desktop settings Validate types for new fields during sanitizeWebSettings * feat(quota): export providers and utilities Expose QUOTA_PROVIDERS and QUOTA_PROVIDER_MAP for consumers Export QuotaProviderMeta type for user code Make formatting and usage resolution utilities available from quota module * feat(quota): define base quota provider interface Define QuotaProvider interface with id, name, isConfigured, and fetchQuota Expose ProviderResult type in fetchQuota contract * feat: add quota providers index and map Expose QUOTA_PROVIDERS with OpenAI, Google and z.ai Provide QUOTA_PROVIDER_MAP for quick provider lookup by id * feat: add quota utils for percent formatting and tone Add clampPercent to sanitize and clamp numbers to 0-100 Add formatPercent to render '-' for null and 'x%' for values Add resolveUsageTone to categorize percent as safe, warn, or critical * feat: add useQuotaStore for quota data Load usage settings from desktop, VSCode, or API at startup Fetch quotas for all providers in parallel and update loading state Expose lastUpdated timestamp and error state for UI feedback * feat: export quota types from quota module Expose quota-related types in UI type definitions Allow downstream code to import QuotaProviderId and related types Aggregate quota exports under the quota module in index * feat: add quota types for usage providers Add QuotaProviderId and UsageWindow shapes to model quota data Add ProviderUsage, ProviderResult, and related usage mapping for providers * feat: add quota provider endpoints API List available quota providers via GET /api/quota/providers Retrieve quota details for a specific provider with GET /api/quota/:providerId Log errors and return 500 with error message on quota fetch failures * feat: add quota providers discovery and formatting Detect configured quota providers from auth and account files Normalize auth entries to tokens or objects for API usage Expose formatted reset times and remaining window metrics * feat: persist usage settings in UsageSidebar and remove from defaults Load usage settings on mount for the sidebar Persist changes to auto-refresh and refresh interval to server Remove usage settings state and effects from DefaultsSettings * fix(usage): guard auto-select in UsagePage when results empty Guard auto-select in UsagePage when results are empty Prevent unexpected provider selection on initial render * fix(quota): set error state during quota updates Reset error to null when a new quota result is added Set error to the error message on fetch failure or fallback Keep error state alongside results in all update paths
This commit is contained in:
@@ -125,6 +125,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
|
||||
const handleModelChange = React.useCallback(async (providerId: string, modelId: string) => {
|
||||
const newValue = providerId && modelId ? `${providerId}/${modelId}` : undefined;
|
||||
setDefaultModel(newValue);
|
||||
@@ -243,6 +244,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
}
|
||||
}, [setSettingsAutoCreateWorktree]);
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
@@ -301,7 +303,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(parsedModel.providerId || defaultAgent) && (
|
||||
{(parsedModel.providerId || defaultAgent) && (
|
||||
<div className="typography-meta text-muted-foreground">
|
||||
New sessions will start with:{' '}
|
||||
{parsedModel.providerId && (
|
||||
@@ -315,6 +317,7 @@ export const DefaultsSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{!isVSCode && (
|
||||
<div className="pt-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import type { UsageWindow } from '@/types';
|
||||
import { formatPercent, formatWindowLabel } from '@/lib/quota';
|
||||
import { UsageProgressBar } from './UsageProgressBar';
|
||||
|
||||
interface UsageCardProps {
|
||||
title: string;
|
||||
window: UsageWindow;
|
||||
subtitle?: string | null;
|
||||
}
|
||||
|
||||
export const UsageCard: React.FC<UsageCardProps> = ({ title, window, subtitle }) => {
|
||||
const percentLabel = formatPercent(window.usedPercent);
|
||||
const resetLabel = window.resetAfterFormatted ?? window.resetAtFormatted ?? '-';
|
||||
const windowLabel = formatWindowLabel(title);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border/60 bg-card/40 p-4 shadow-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="typography-ui-label text-foreground truncate">{windowLabel}</div>
|
||||
{subtitle && (
|
||||
<div className="typography-micro text-muted-foreground truncate">{subtitle}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="typography-ui-label text-foreground tabular-nums">{percentLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<UsageProgressBar percent={window.usedPercent} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-muted-foreground">
|
||||
<span className="typography-micro">Resets in</span>
|
||||
<span className="typography-micro tabular-nums">{resetLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { UsageCard } from './UsageCard';
|
||||
import { QUOTA_PROVIDERS } from '@/lib/quota';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return '-';
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
export const UsagePage: React.FC = () => {
|
||||
const results = useQuotaStore((state) => state.results);
|
||||
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
|
||||
const loadSettings = useQuotaStore((state) => state.loadSettings);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const isLoading = useQuotaStore((state) => state.isLoading);
|
||||
const lastUpdated = useQuotaStore((state) => state.lastUpdated);
|
||||
const error = useQuotaStore((state) => state.error);
|
||||
|
||||
useQuotaAutoRefresh();
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadSettings();
|
||||
void fetchAllQuotas();
|
||||
}, [loadSettings, fetchAllQuotas]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedProviderId) {
|
||||
return;
|
||||
}
|
||||
if (results.length === 0) {
|
||||
return;
|
||||
}
|
||||
const firstConfigured = results.find((entry) => entry.configured)?.providerId;
|
||||
setSelectedProvider(firstConfigured ?? QUOTA_PROVIDERS[0]?.id ?? null);
|
||||
}, [results, selectedProviderId, setSelectedProvider]);
|
||||
|
||||
const selectedResult = results.find((entry) => entry.providerId === selectedProviderId) ?? null;
|
||||
|
||||
if (!selectedProviderId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p className="typography-body">Select a provider to view usage details.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const providerMeta = QUOTA_PROVIDERS.find((provider) => provider.id === selectedProviderId);
|
||||
const providerName = providerMeta?.name ?? selectedProviderId;
|
||||
const usage = selectedResult?.usage;
|
||||
|
||||
return (
|
||||
<ScrollableOverlay keyboardAvoid outerClassName="h-full" className="w-full">
|
||||
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
||||
<div className="space-y-1">
|
||||
<h1 className="typography-ui-header font-semibold text-lg">{providerName} Usage</h1>
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{isLoading ? 'Refreshing usage...' : `Last updated ${formatTime(lastUpdated)}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!selectedResult && (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
|
||||
<p className="typography-body">No usage data available yet.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
|
||||
<p className="typography-body">Failed to refresh usage data.</p>
|
||||
<p className="typography-meta mt-1">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResult && !selectedResult.configured && (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
|
||||
<p className="typography-body">Provider is not configured yet.</p>
|
||||
<p className="typography-meta mt-1">
|
||||
Add credentials in the Providers tab to enable usage tracking.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage?.windows && Object.keys(usage.windows).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(usage.windows).map(([label, window]) => (
|
||||
<UsageCard key={label} title={label} window={window} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{usage?.models && Object.keys(usage.models).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="typography-ui-header font-semibold text-foreground">Model Quotas</div>
|
||||
{Object.entries(usage.models).map(([modelName, modelUsage]) => {
|
||||
const entries = Object.entries(modelUsage.windows);
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const [label, window] = entries[0];
|
||||
return <UsageCard key={modelName} title={label} subtitle={modelName} window={window} />;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedResult?.configured && usage && Object.keys(usage.windows ?? {}).length === 0 &&
|
||||
Object.keys(usage.models ?? {}).length === 0 && (
|
||||
<div className="rounded-lg border border-border/60 bg-card/40 p-4 text-muted-foreground">
|
||||
<p className="typography-body">No quota windows reported for this provider.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollableOverlay>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { clampPercent, resolveUsageTone } from '@/lib/quota';
|
||||
|
||||
interface UsageProgressBarProps {
|
||||
percent: number | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const UsageProgressBar: React.FC<UsageProgressBarProps> = ({ percent, className }) => {
|
||||
const clamped = clampPercent(percent) ?? 0;
|
||||
const tone = resolveUsageTone(percent);
|
||||
|
||||
const fillClass = tone === 'critical'
|
||||
? 'from-rose-500 to-rose-400'
|
||||
: tone === 'warn'
|
||||
? 'from-amber-500 to-amber-400'
|
||||
: 'from-emerald-500 to-emerald-400';
|
||||
|
||||
return (
|
||||
<div className={cn('h-2.5 rounded-full bg-muted/60 overflow-hidden', className)}>
|
||||
<div
|
||||
className={cn('h-full bg-gradient-to-r transition-all duration-300', fillClass)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={clamped}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from 'react';
|
||||
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import { updateDesktopSettings } from '@/lib/persistence';
|
||||
import { RiRefreshLine } from '@remixicon/react';
|
||||
|
||||
interface UsageSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
}
|
||||
|
||||
const getUsagePercent = (usage: { windows?: Record<string, { usedPercent: number | null }> } | null | undefined) => {
|
||||
const windows = usage?.windows ?? {};
|
||||
const values = Object.values(windows)
|
||||
.map((window) => window.usedPercent)
|
||||
.filter((value): value is number => typeof value === 'number');
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(...values);
|
||||
};
|
||||
|
||||
export const UsageSidebar: React.FC<UsageSidebarProps> = ({ onItemSelect }) => {
|
||||
const results = useQuotaStore((state) => state.results);
|
||||
const selectedProviderId = useQuotaStore((state) => state.selectedProviderId);
|
||||
const setSelectedProvider = useQuotaStore((state) => state.setSelectedProvider);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const isLoading = useQuotaStore((state) => state.isLoading);
|
||||
const usageAutoRefresh = useQuotaStore((state) => state.autoRefresh);
|
||||
const usageRefreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
|
||||
const setUsageAutoRefresh = useQuotaStore((state) => state.setAutoRefresh);
|
||||
const setUsageRefreshInterval = useQuotaStore((state) => state.setRefreshInterval);
|
||||
const loadUsageSettings = useQuotaStore((state) => state.loadSettings);
|
||||
const { isMobile } = useDeviceInfo();
|
||||
|
||||
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return typeof window.opencodeDesktop !== 'undefined';
|
||||
});
|
||||
|
||||
const isVSCode = React.useMemo(() => isVSCodeRuntime(), []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
setIsDesktopRuntime(typeof window.opencodeDesktop !== 'undefined');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadUsageSettings();
|
||||
}, [loadUsageSettings]);
|
||||
|
||||
const persistUsageSettings = React.useCallback(async (changes: { usageAutoRefresh?: boolean; usageRefreshIntervalMs?: number }) => {
|
||||
try {
|
||||
await updateDesktopSettings(changes);
|
||||
} catch (error) {
|
||||
console.warn('Failed to save usage settings:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleUsageAutoRefreshChange = React.useCallback((enabled: boolean) => {
|
||||
setUsageAutoRefresh(enabled);
|
||||
void persistUsageSettings({ usageAutoRefresh: enabled });
|
||||
}, [persistUsageSettings, setUsageAutoRefresh]);
|
||||
|
||||
const handleUsageRefreshIntervalChange = React.useCallback((value: string) => {
|
||||
const next = Number(value);
|
||||
if (!Number.isFinite(next)) {
|
||||
return;
|
||||
}
|
||||
setUsageRefreshInterval(next);
|
||||
void persistUsageSettings({ usageRefreshIntervalMs: next });
|
||||
}, [persistUsageSettings, setUsageRefreshInterval]);
|
||||
|
||||
const bgClass = isDesktopRuntime
|
||||
? 'bg-transparent'
|
||||
: isVSCode
|
||||
? 'bg-background'
|
||||
: 'bg-sidebar';
|
||||
|
||||
return (
|
||||
<div className={cn('flex h-full flex-col', bgClass)}>
|
||||
<div className={cn('border-b px-3', isMobile ? 'mt-2 py-3' : 'py-3')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">Total {QUOTA_PROVIDERS.length}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={usageAutoRefresh}
|
||||
onCheckedChange={handleUsageAutoRefreshChange}
|
||||
aria-label="Toggle auto refresh"
|
||||
/>
|
||||
<Select
|
||||
value={String(usageRefreshIntervalMs)}
|
||||
onValueChange={handleUsageRefreshIntervalChange}
|
||||
disabled={!usageAutoRefresh}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-[72px]">
|
||||
<SelectValue placeholder="Interval" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="30000" className="pr-2 [&>span:first-child]:hidden">30s</SelectItem>
|
||||
<SelectItem value="60000" className="pr-2 [&>span:first-child]:hidden">1m</SelectItem>
|
||||
<SelectItem value="300000" className="pr-2 [&>span:first-child]:hidden">5m</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => fetchAllQuotas()}
|
||||
aria-label="Refresh usage"
|
||||
title="Refresh usage"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RiRefreshLine className={cn('size-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
|
||||
{QUOTA_PROVIDERS.map((provider) => {
|
||||
const result = results.find((entry) => entry.providerId === provider.id);
|
||||
const percent = getUsagePercent(result?.usage);
|
||||
const tone = resolveUsageTone(percent);
|
||||
const isSelected = provider.id === selectedProviderId;
|
||||
const configured = result?.configured ?? false;
|
||||
|
||||
const statusClass = !configured
|
||||
? 'bg-muted-foreground/40'
|
||||
: tone === 'critical'
|
||||
? 'bg-rose-500'
|
||||
: tone === 'warn'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-emerald-500';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={provider.id}
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-md px-1.5 py-1 transition-all duration-200',
|
||||
isSelected ? 'dark:bg-accent/80 bg-primary/12' : 'hover:dark:bg-accent/40 hover:bg-primary/6'
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedProvider(provider.id);
|
||||
onItemSelect?.();
|
||||
}}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||
>
|
||||
<span className={cn('h-2.5 w-2.5 rounded-full flex-shrink-0', statusClass)} />
|
||||
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="typography-ui-label font-normal truncate flex-1 min-w-0 text-foreground">
|
||||
{provider.name}
|
||||
</span>
|
||||
{!configured && (
|
||||
<span className="typography-micro text-muted-foreground/60 flex-shrink-0">Not set</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollableOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -25,6 +25,8 @@ import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
|
||||
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
|
||||
import { ProvidersSidebar } from '@/components/sections/providers/ProvidersSidebar';
|
||||
import { ProvidersPage } from '@/components/sections/providers/ProvidersPage';
|
||||
import { UsageSidebar } from '@/components/sections/usage/UsageSidebar';
|
||||
import { UsagePage } from '@/components/sections/usage/UsagePage';
|
||||
import { GitIdentitiesSidebar } from '@/components/sections/git-identities/GitIdentitiesSidebar';
|
||||
import { GitIdentitiesPage } from '@/components/sections/git-identities/GitIdentitiesPage';
|
||||
import { OpenChamberPage } from '@/components/sections/openchamber/OpenChamberPage';
|
||||
@@ -305,6 +307,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <SkillsSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'providers':
|
||||
return <ProvidersSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'usage':
|
||||
return <UsageSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
case 'git-identities':
|
||||
return <GitIdentitiesSidebar onItemSelect={handleMobileSidebarClick} />;
|
||||
default:
|
||||
@@ -324,6 +328,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <SkillsPage />;
|
||||
case 'providers':
|
||||
return <ProvidersPage />;
|
||||
case 'usage':
|
||||
return <UsagePage />;
|
||||
case 'git-identities':
|
||||
return <GitIdentitiesPage />;
|
||||
case 'settings':
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine, RiBookLine } from '@remixicon/react';
|
||||
import { RiBrainAi3Line, RiChatAi3Line, RiCommandLine, RiGitBranchLine, RiSettings3Line, RiStackLine, RiBookLine, RiBarChart2Line } from '@remixicon/react';
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'providers' | 'git-identities' | 'settings';
|
||||
export type SidebarSection = 'sessions' | 'agents' | 'commands' | 'skills' | 'providers' | 'usage' | 'git-identities' | 'settings';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type IconComponent = ComponentType<any>;
|
||||
@@ -44,6 +44,12 @@ export const SIDEBAR_SECTIONS: SidebarSectionConfig[] = [
|
||||
description: 'Configure AI model providers and API credentials.',
|
||||
icon: RiStackLine,
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
label: 'Usage',
|
||||
description: 'Monitor API quota and usage across providers.',
|
||||
icon: RiBarChart2Line,
|
||||
},
|
||||
{
|
||||
id: 'git-identities',
|
||||
label: 'Git Identities',
|
||||
|
||||
@@ -55,6 +55,8 @@ export type DesktopSettings = {
|
||||
nativeNotificationsEnabled?: boolean;
|
||||
notificationMode?: 'always' | 'hidden-only';
|
||||
notifyOnSubtasks?: boolean;
|
||||
usageAutoRefresh?: boolean;
|
||||
usageRefreshIntervalMs?: number;
|
||||
autoDeleteEnabled?: boolean;
|
||||
autoDeleteAfterDays?: number;
|
||||
defaultModel?: string; // format: "provider/model"
|
||||
|
||||
@@ -351,6 +351,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => {
|
||||
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
||||
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
||||
}
|
||||
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
||||
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
||||
}
|
||||
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
||||
result.usageRefreshIntervalMs = candidate.usageRefreshIntervalMs;
|
||||
}
|
||||
if (
|
||||
typeof candidate.toolCallExpansion === 'string'
|
||||
&& (candidate.toolCallExpansion === 'collapsed'
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export { QUOTA_PROVIDERS, QUOTA_PROVIDER_MAP } from './providers';
|
||||
export type { QuotaProviderMeta } from './providers';
|
||||
export { clampPercent, formatPercent, resolveUsageTone, formatWindowLabel } from './utils';
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ProviderResult, QuotaProviderId } from '@/types';
|
||||
|
||||
export interface QuotaProvider {
|
||||
id: QuotaProviderId;
|
||||
name: string;
|
||||
isConfigured: () => Promise<boolean>;
|
||||
fetchQuota: () => Promise<ProviderResult>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { QuotaProviderId } from '@/types';
|
||||
|
||||
export interface QuotaProviderMeta {
|
||||
id: QuotaProviderId;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const QUOTA_PROVIDERS: QuotaProviderMeta[] = [
|
||||
{ id: 'openai', name: 'OpenAI' },
|
||||
{ id: 'google', name: 'Google' },
|
||||
{ id: 'zai-coding-plan', name: 'z.ai' }
|
||||
];
|
||||
|
||||
export const QUOTA_PROVIDER_MAP = QUOTA_PROVIDERS.reduce<Record<string, QuotaProviderMeta>>(
|
||||
(acc, provider) => {
|
||||
acc[provider.id] = provider;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
export const clampPercent = (value: number | null): number | null => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
};
|
||||
|
||||
export const formatPercent = (value: number | null): string => {
|
||||
const clamped = clampPercent(value);
|
||||
if (clamped === null) {
|
||||
return '-';
|
||||
}
|
||||
return `${clamped}%`;
|
||||
};
|
||||
|
||||
export const resolveUsageTone = (percent: number | null): 'safe' | 'warn' | 'critical' => {
|
||||
if (percent === null) {
|
||||
return 'safe';
|
||||
}
|
||||
if (percent >= 80) {
|
||||
return 'critical';
|
||||
}
|
||||
if (percent >= 50) {
|
||||
return 'warn';
|
||||
}
|
||||
return 'safe';
|
||||
};
|
||||
|
||||
export const formatWindowLabel = (label: string): string => {
|
||||
if (label === '5h') return '5-Hour Limit';
|
||||
if (label === 'weekly') return 'Weekly Limit';
|
||||
return label;
|
||||
};
|
||||
@@ -0,0 +1,180 @@
|
||||
import React from 'react';
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
import type { ProviderResult, QuotaProviderId } from '@/types';
|
||||
import { QUOTA_PROVIDERS } from '@/lib/quota';
|
||||
import { getDesktopSettings, isDesktopRuntime, isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
|
||||
const DEFAULT_REFRESH_INTERVAL_MS = 60000;
|
||||
|
||||
interface QuotaSettingsState {
|
||||
autoRefresh: boolean;
|
||||
refreshIntervalMs: number;
|
||||
}
|
||||
|
||||
interface QuotaStore extends QuotaSettingsState {
|
||||
results: ProviderResult[];
|
||||
selectedProviderId: QuotaProviderId | null;
|
||||
isLoading: boolean;
|
||||
isFetchingProvider: Record<string, boolean>;
|
||||
lastUpdated: number | null;
|
||||
error: string | null;
|
||||
|
||||
loadSettings: () => Promise<void>;
|
||||
fetchAllQuotas: () => Promise<void>;
|
||||
fetchProviderQuota: (providerId: QuotaProviderId) => Promise<void>;
|
||||
setSelectedProvider: (providerId: QuotaProviderId | null) => void;
|
||||
setAutoRefresh: (enabled: boolean) => void;
|
||||
setRefreshInterval: (intervalMs: number) => void;
|
||||
}
|
||||
|
||||
const parseSettings = (data: Record<string, unknown> | null): QuotaSettingsState => {
|
||||
const autoRefresh = typeof data?.usageAutoRefresh === 'boolean'
|
||||
? data.usageAutoRefresh
|
||||
: false;
|
||||
const refreshIntervalMs =
|
||||
typeof data?.usageRefreshIntervalMs === 'number' && Number.isFinite(data.usageRefreshIntervalMs)
|
||||
? Math.max(30000, Math.min(300000, Math.round(data.usageRefreshIntervalMs)))
|
||||
: DEFAULT_REFRESH_INTERVAL_MS;
|
||||
|
||||
return { autoRefresh, refreshIntervalMs };
|
||||
};
|
||||
|
||||
const loadSettingsFromRuntime = async (): Promise<QuotaSettingsState> => {
|
||||
if (isDesktopRuntime()) {
|
||||
const data = await getDesktopSettings();
|
||||
return parseSettings((data as Record<string, unknown>) ?? null);
|
||||
}
|
||||
|
||||
const runtimeSettings = getRegisteredRuntimeAPIs()?.settings;
|
||||
if (runtimeSettings) {
|
||||
try {
|
||||
const result = await runtimeSettings.load();
|
||||
const settings = result?.settings as Record<string, unknown> | undefined;
|
||||
return parseSettings(settings ?? null);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
if (!isVSCodeRuntime()) {
|
||||
const response = await fetch('/api/config/settings', {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return parseSettings(data as Record<string, unknown> | null);
|
||||
}
|
||||
}
|
||||
|
||||
return { autoRefresh: false, refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS };
|
||||
};
|
||||
|
||||
export const useQuotaStore = create<QuotaStore>()(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
results: [],
|
||||
selectedProviderId: null,
|
||||
isLoading: false,
|
||||
isFetchingProvider: {},
|
||||
lastUpdated: null,
|
||||
error: null,
|
||||
autoRefresh: false,
|
||||
refreshIntervalMs: DEFAULT_REFRESH_INTERVAL_MS,
|
||||
|
||||
loadSettings: async () => {
|
||||
try {
|
||||
const settings = await loadSettingsFromRuntime();
|
||||
set(settings);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load usage settings:', error);
|
||||
}
|
||||
},
|
||||
|
||||
fetchAllQuotas: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
const providerIds = QUOTA_PROVIDERS.map((provider) => provider.id);
|
||||
try {
|
||||
await Promise.all(
|
||||
providerIds.map((providerId) => get().fetchProviderQuota(providerId))
|
||||
);
|
||||
set({
|
||||
isLoading: false,
|
||||
lastUpdated: Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch quotas';
|
||||
set({ isLoading: false, error: message });
|
||||
}
|
||||
},
|
||||
|
||||
fetchProviderQuota: async (providerId) => {
|
||||
set((state) => ({
|
||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: true }
|
||||
}));
|
||||
try {
|
||||
const response = await fetch(`/api/quota/${encodeURIComponent(providerId)}`);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.error || 'Failed to fetch quota');
|
||||
}
|
||||
|
||||
const result = payload as ProviderResult;
|
||||
set((state) => {
|
||||
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
||||
next.push(result);
|
||||
return { results: next, error: null };
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch quota';
|
||||
const fallback: ProviderResult = {
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: message,
|
||||
usage: null,
|
||||
fetchedAt: Date.now()
|
||||
};
|
||||
set((state) => {
|
||||
const next = state.results.filter((entry) => entry.providerId !== providerId);
|
||||
next.push(fallback);
|
||||
return { results: next, error: message };
|
||||
});
|
||||
} finally {
|
||||
set((state) => ({
|
||||
isFetchingProvider: { ...state.isFetchingProvider, [providerId]: false }
|
||||
}));
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedProvider: (providerId) => set({ selectedProviderId: providerId }),
|
||||
setAutoRefresh: (enabled) => set({ autoRefresh: enabled }),
|
||||
setRefreshInterval: (intervalMs) => {
|
||||
const clamped = Math.max(30000, Math.min(300000, Math.round(intervalMs)));
|
||||
set({ refreshIntervalMs: clamped });
|
||||
}
|
||||
}),
|
||||
{ name: 'quota-store' }
|
||||
)
|
||||
);
|
||||
|
||||
export const useQuotaAutoRefresh = () => {
|
||||
const autoRefresh = useQuotaStore((state) => state.autoRefresh);
|
||||
const refreshIntervalMs = useQuotaStore((state) => state.refreshIntervalMs);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
fetchAllQuotas();
|
||||
}, refreshIntervalMs);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [autoRefresh, refreshIntervalMs, fetchAllQuotas]);
|
||||
};
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { Session, Message, Part, Provider } from "@opencode-ai/sdk/v2";
|
||||
|
||||
export type { Session, Message, Part, Provider };
|
||||
export type {
|
||||
QuotaProviderId,
|
||||
UsageWindow,
|
||||
UsageWindows,
|
||||
ProviderUsage,
|
||||
ProviderResult
|
||||
} from './quota';
|
||||
|
||||
export interface ChatState {
|
||||
sessions: Session[];
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export type QuotaProviderId = 'openai' | 'google' | 'zai-coding-plan';
|
||||
|
||||
export interface UsageWindow {
|
||||
usedPercent: number | null;
|
||||
remainingPercent: number | null;
|
||||
windowSeconds: number | null;
|
||||
resetAfterSeconds: number | null;
|
||||
resetAt: number | null;
|
||||
resetAtFormatted: string | null;
|
||||
resetAfterFormatted: string | null;
|
||||
}
|
||||
|
||||
export interface UsageWindows {
|
||||
windows: Record<string, UsageWindow>;
|
||||
}
|
||||
|
||||
export interface ProviderUsage extends UsageWindows {
|
||||
models?: Record<string, UsageWindows>;
|
||||
}
|
||||
|
||||
export interface ProviderResult {
|
||||
providerId: QuotaProviderId;
|
||||
providerName: string;
|
||||
ok: boolean;
|
||||
configured: boolean;
|
||||
error?: string;
|
||||
usage: ProviderUsage | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
@@ -978,6 +978,12 @@ const sanitizeSettingsUpdate = (payload) => {
|
||||
if (typeof candidate.notifyOnSubtasks === 'boolean') {
|
||||
result.notifyOnSubtasks = candidate.notifyOnSubtasks;
|
||||
}
|
||||
if (typeof candidate.usageAutoRefresh === 'boolean') {
|
||||
result.usageAutoRefresh = candidate.usageAutoRefresh;
|
||||
}
|
||||
if (typeof candidate.usageRefreshIntervalMs === 'number' && Number.isFinite(candidate.usageRefreshIntervalMs)) {
|
||||
result.usageRefreshIntervalMs = Math.max(30000, Math.min(300000, Math.round(candidate.usageRefreshIntervalMs)));
|
||||
}
|
||||
if (typeof candidate.autoDeleteEnabled === 'boolean') {
|
||||
result.autoDeleteEnabled = candidate.autoDeleteEnabled;
|
||||
}
|
||||
@@ -4358,6 +4364,14 @@ async function main(options = {}) {
|
||||
return authLibrary;
|
||||
};
|
||||
|
||||
let quotaProviders = null;
|
||||
const getQuotaProviders = async () => {
|
||||
if (!quotaProviders) {
|
||||
quotaProviders = await import('./lib/quota-providers.js');
|
||||
}
|
||||
return quotaProviders;
|
||||
};
|
||||
|
||||
// ================= GitHub OAuth (Device Flow) =================
|
||||
|
||||
// Note: scopes may be overridden via OPENCHAMBER_GITHUB_SCOPES or settings.json (see github-auth.js).
|
||||
@@ -5489,6 +5503,32 @@ async function main(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/quota/providers', async (_req, res) => {
|
||||
try {
|
||||
const { listConfiguredQuotaProviders } = await getQuotaProviders();
|
||||
const providers = listConfiguredQuotaProviders();
|
||||
res.json({ providers });
|
||||
} catch (error) {
|
||||
console.error('Failed to list quota providers:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to list quota providers' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/quota/:providerId', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
if (!providerId) {
|
||||
return res.status(400).json({ error: 'Provider ID is required' });
|
||||
}
|
||||
const { fetchQuotaForProvider } = await getQuotaProviders();
|
||||
const result = await fetchQuotaForProvider(providerId);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch quota:', error);
|
||||
res.status(500).json({ error: error.message || 'Failed to fetch quota' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/provider/:providerId/auth', async (req, res) => {
|
||||
try {
|
||||
const { providerId } = req.params;
|
||||
|
||||
@@ -0,0 +1,509 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
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')
|
||||
];
|
||||
|
||||
const readJsonFile = (filePath) => {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
return JSON.parse(trimmed);
|
||||
} catch (error) {
|
||||
console.warn(`Failed to read JSON file: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getAuthEntry = (auth, aliases) => {
|
||||
for (const alias of aliases) {
|
||||
if (auth[alias]) {
|
||||
return auth[alias];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const normalizeAuthEntry = (entry) => {
|
||||
if (!entry) return null;
|
||||
if (typeof entry === 'string') {
|
||||
return { token: entry };
|
||||
}
|
||||
if (typeof entry === 'object') {
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatResetAt = (timestamp) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDuration = (seconds) => {
|
||||
if (typeof seconds !== 'number' || Number.isNaN(seconds)) {
|
||||
return null;
|
||||
}
|
||||
const clamped = Math.max(0, Math.round(seconds));
|
||||
const hours = Math.floor(clamped / 3600);
|
||||
const minutes = Math.floor((clamped % 3600) / 60);
|
||||
if (hours === 0 && minutes === 0) {
|
||||
return '0m';
|
||||
}
|
||||
if (hours === 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
if (minutes === 0) {
|
||||
return `${hours}h`;
|
||||
}
|
||||
return `${hours}h ${minutes}m`;
|
||||
};
|
||||
|
||||
const calculateResetAfterSeconds = (resetAt) => {
|
||||
if (!resetAt) return null;
|
||||
const delta = Math.floor((resetAt - Date.now()) / 1000);
|
||||
return delta < 0 ? 0 : delta;
|
||||
};
|
||||
|
||||
const toUsageWindow = ({ usedPercent, windowSeconds, resetAt }) => {
|
||||
const resetAfterSeconds = calculateResetAfterSeconds(resetAt);
|
||||
return {
|
||||
usedPercent,
|
||||
remainingPercent: usedPercent !== null ? Math.max(0, 100 - usedPercent) : null,
|
||||
windowSeconds: windowSeconds ?? null,
|
||||
resetAfterSeconds,
|
||||
resetAt,
|
||||
resetAtFormatted: resetAt ? formatResetAt(resetAt) : null,
|
||||
resetAfterFormatted: resetAfterSeconds !== null ? formatDuration(resetAfterSeconds) : null
|
||||
};
|
||||
};
|
||||
|
||||
const buildResult = ({ providerId, providerName, ok, configured, usage, error }) => ({
|
||||
providerId,
|
||||
providerName,
|
||||
ok,
|
||||
configured,
|
||||
usage: usage ?? null,
|
||||
...(error ? { error } : {}),
|
||||
fetchedAt: Date.now()
|
||||
});
|
||||
|
||||
export const listConfiguredQuotaProviders = () => {
|
||||
const auth = readAuthFile();
|
||||
const configured = new Set();
|
||||
|
||||
const openaiAuth = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt']));
|
||||
if (openaiAuth?.access || openaiAuth?.token) {
|
||||
configured.add('openai');
|
||||
}
|
||||
|
||||
const googleAuth = normalizeAuthEntry(getAuthEntry(auth, ['google', 'antigravity']));
|
||||
if (googleAuth?.access || googleAuth?.token || googleAuth?.refresh) {
|
||||
configured.add('google');
|
||||
}
|
||||
|
||||
const zaiAuth = normalizeAuthEntry(getAuthEntry(auth, ['zai-coding-plan', 'zai', 'z.ai']));
|
||||
if (zaiAuth?.key || zaiAuth?.token) {
|
||||
configured.add('zai-coding-plan');
|
||||
}
|
||||
|
||||
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
|
||||
const data = readJsonFile(filePath);
|
||||
if (Array.isArray(data?.accounts) && data.accounts.length > 0) {
|
||||
configured.add('google');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(configured);
|
||||
};
|
||||
|
||||
export const fetchOpenaiQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['openai', 'codex', 'chatgpt']));
|
||||
const accessToken = entry?.access ?? entry?.token;
|
||||
|
||||
if (!accessToken) {
|
||||
return buildResult({
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
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 windows = {};
|
||||
if (primary) {
|
||||
windows['5h'] = toUsageWindow({
|
||||
usedPercent: primary.used_percent ?? null,
|
||||
windowSeconds: primary.limit_window_seconds ?? null,
|
||||
resetAt: primary.reset_at ? primary.reset_at * 1000 : null
|
||||
});
|
||||
}
|
||||
if (secondary) {
|
||||
windows['weekly'] = toUsageWindow({
|
||||
usedPercent: secondary.used_percent ?? null,
|
||||
windowSeconds: secondary.limit_window_seconds ?? null,
|
||||
resetAt: secondary.reset_at ? secondary.reset_at * 1000 : null
|
||||
});
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId: 'openai',
|
||||
providerName: 'OpenAI',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const GOOGLE_CLIENT_ID =
|
||||
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
|
||||
const GOOGLE_CLIENT_SECRET = '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';
|
||||
const DEFAULT_PROJECT_ID = 'rising-fact-p41fc';
|
||||
const GOOGLE_WINDOW_SECONDS = 5 * 60 * 60;
|
||||
|
||||
const GOOGLE_ENDPOINTS = [
|
||||
'https://daily-cloudcode-pa.sandbox.googleapis.com',
|
||||
'https://autopush-cloudcode-pa.sandbox.googleapis.com',
|
||||
'https://cloudcode-pa.googleapis.com'
|
||||
];
|
||||
|
||||
const GOOGLE_HEADERS = {
|
||||
'User-Agent': 'antigravity/1.11.5 windows/amd64',
|
||||
'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
|
||||
'Client-Metadata':
|
||||
'{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'
|
||||
};
|
||||
|
||||
const resolveGoogleAuth = () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['google', 'antigravity']));
|
||||
if (entry) {
|
||||
const accessToken = entry.access ?? entry.token;
|
||||
let refreshToken = entry.refresh;
|
||||
let projectId = undefined;
|
||||
if (refreshToken && refreshToken.includes('|')) {
|
||||
const parts = refreshToken.split('|');
|
||||
refreshToken = parts[0];
|
||||
projectId = parts[1];
|
||||
}
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expires: entry.expires,
|
||||
projectId
|
||||
};
|
||||
}
|
||||
|
||||
for (const filePath of ANTIGRAVITY_ACCOUNTS_PATHS) {
|
||||
const data = readJsonFile(filePath);
|
||||
const accounts = data?.accounts;
|
||||
if (Array.isArray(accounts) && accounts.length > 0) {
|
||||
const index = typeof data.activeIndex === 'number' ? data.activeIndex : 0;
|
||||
const account = accounts[index] ?? accounts[0];
|
||||
if (account?.refreshToken) {
|
||||
return {
|
||||
refreshToken: account.refreshToken,
|
||||
projectId: account.projectId ?? account.managedProjectId,
|
||||
email: account.email
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const refreshGoogleAccessToken = async (refreshToken) => {
|
||||
const response = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
client_secret: GOOGLE_CLIENT_SECRET,
|
||||
refresh_token: refreshToken,
|
||||
grant_type: 'refresh_token'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return typeof data?.access_token === 'string' ? data.access_token : null;
|
||||
};
|
||||
|
||||
const fetchGoogleModels = async (accessToken, projectId) => {
|
||||
const body = projectId ? { project: projectId } : {};
|
||||
|
||||
for (const endpoint of GOOGLE_ENDPOINTS) {
|
||||
try {
|
||||
const response = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
...GOOGLE_HEADERS
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15000)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const fetchGoogleQuota = async () => {
|
||||
const auth = resolveGoogleAuth();
|
||||
if (!auth) {
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
let accessToken = auth.accessToken;
|
||||
if (!accessToken || (typeof auth.expires === 'number' && auth.expires <= now)) {
|
||||
if (!auth.refreshToken) {
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Missing refresh token'
|
||||
});
|
||||
}
|
||||
accessToken = await refreshGoogleAccessToken(auth.refreshToken);
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Failed to refresh OAuth token'
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = auth.projectId ?? DEFAULT_PROJECT_ID;
|
||||
const payload = await fetchGoogleModels(accessToken, projectId);
|
||||
if (!payload) {
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: 'Failed to fetch models'
|
||||
});
|
||||
}
|
||||
|
||||
const models = {};
|
||||
for (const [modelName, modelData] of Object.entries(payload.models ?? {})) {
|
||||
const remainingFraction = modelData?.quotaInfo?.remainingFraction;
|
||||
const remainingPercent = typeof remainingFraction === 'number'
|
||||
? Math.round(remainingFraction * 100)
|
||||
: null;
|
||||
const usedPercent = remainingPercent !== null ? Math.max(0, 100 - remainingPercent) : null;
|
||||
const resetAt = modelData?.quotaInfo?.resetTime
|
||||
? new Date(modelData.quotaInfo.resetTime).getTime()
|
||||
: null;
|
||||
models[modelName] = {
|
||||
windows: {
|
||||
'5h': toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds: GOOGLE_WINDOW_SECONDS,
|
||||
resetAt
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId: 'google',
|
||||
providerName: 'Google',
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: {
|
||||
windows: {},
|
||||
models: Object.keys(models).length ? models : undefined
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeTimestamp = (value) => {
|
||||
if (typeof value !== 'number') return null;
|
||||
return value < 1_000_000_000_000 ? value * 1000 : value;
|
||||
};
|
||||
|
||||
const ZAI_TOKEN_WINDOW_SECONDS = { 3: 3600 };
|
||||
|
||||
const resolveWindowSeconds = (limit) => {
|
||||
if (!limit || !limit.number) return null;
|
||||
const unitSeconds = ZAI_TOKEN_WINDOW_SECONDS[limit.unit];
|
||||
if (!unitSeconds) return null;
|
||||
return unitSeconds * limit.number;
|
||||
};
|
||||
|
||||
const resolveWindowLabel = (windowSeconds) => {
|
||||
if (!windowSeconds) return 'tokens';
|
||||
if (windowSeconds % 86400 === 0) {
|
||||
const days = windowSeconds / 86400;
|
||||
return days === 7 ? 'weekly' : `${days}d`;
|
||||
}
|
||||
if (windowSeconds % 3600 === 0) {
|
||||
return `${windowSeconds / 3600}h`;
|
||||
}
|
||||
return `${windowSeconds}s`;
|
||||
};
|
||||
|
||||
export const fetchZaiQuota = async () => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['zai-coding-plan', 'zai', 'z.ai']));
|
||||
const apiKey = entry?.key ?? entry?.token;
|
||||
|
||||
if (!apiKey) {
|
||||
return buildResult({
|
||||
providerId: 'zai-coding-plan',
|
||||
providerName: 'z.ai',
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Not configured'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.z.ai/api/monitor/usage/quota/limit', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return buildResult({
|
||||
providerId: 'zai-coding-plan',
|
||||
providerName: 'z.ai',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: `API error: ${response.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const limits = Array.isArray(payload?.data?.limits) ? payload.data.limits : [];
|
||||
const tokensLimit = limits.find((limit) => limit?.type === 'TOKENS_LIMIT');
|
||||
const windowSeconds = resolveWindowSeconds(tokensLimit);
|
||||
const windowLabel = resolveWindowLabel(windowSeconds);
|
||||
const resetAt = tokensLimit?.nextResetTime ? normalizeTimestamp(tokensLimit.nextResetTime) : null;
|
||||
const usedPercent = typeof tokensLimit?.percentage === 'number' ? tokensLimit.percentage : null;
|
||||
|
||||
const windows = {};
|
||||
if (tokensLimit) {
|
||||
windows[windowLabel] = toUsageWindow({
|
||||
usedPercent,
|
||||
windowSeconds,
|
||||
resetAt
|
||||
});
|
||||
}
|
||||
|
||||
return buildResult({
|
||||
providerId: 'zai-coding-plan',
|
||||
providerName: 'z.ai',
|
||||
ok: true,
|
||||
configured: true,
|
||||
usage: { windows }
|
||||
});
|
||||
} catch (error) {
|
||||
return buildResult({
|
||||
providerId: 'zai-coding-plan',
|
||||
providerName: 'z.ai',
|
||||
ok: false,
|
||||
configured: true,
|
||||
error: error instanceof Error ? error.message : 'Request failed'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = async (providerId) => {
|
||||
switch (providerId) {
|
||||
case 'openai':
|
||||
return fetchOpenaiQuota();
|
||||
case 'google':
|
||||
return fetchGoogleQuota();
|
||||
case 'zai-coding-plan':
|
||||
return fetchZaiQuota();
|
||||
default:
|
||||
return buildResult({
|
||||
providerId,
|
||||
providerName: providerId,
|
||||
ok: false,
|
||||
configured: false,
|
||||
error: 'Unsupported provider'
|
||||
});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user