feat: add unified provider usage tracker for all AI providers

Implements a comprehensive usage tracking system for Claude, Cursor, Codex,
Gemini, GitHub Copilot, OpenCode, MiniMax, and GLM providers. Based on
CodexBar reference implementation.

- Add unified provider usage types in @automaker/types
- Implement usage services for each provider with appropriate auth
- Create unified ProviderUsageTracker service with 60s caching
- Add API routes for fetching provider usage data
- Add React Query hooks with polling support
- Create ProviderUsageBar UI component for board header
- Replace single-provider UsagePopover with unified bar

https://claude.ai/code/session_018msdfAb9sirVp5b5ZGi4Eo
This commit is contained in:
Claude
2026-01-23 02:33:12 +00:00
parent 01859f3a9a
commit 7941deffd7
18 changed files with 3063 additions and 5 deletions

View File

@@ -83,6 +83,8 @@ import { getNotificationService } from './services/notification-service.js';
import { createEventHistoryRoutes } from './routes/event-history/index.js';
import { getEventHistoryService } from './services/event-history-service.js';
import { getTestRunnerService } from './services/test-runner-service.js';
import { createProviderUsageRoutes } from './routes/provider-usage/index.js';
import { ProviderUsageTracker } from './services/provider-usage-tracker.js';
// Load environment variables
dotenv.config();
@@ -236,6 +238,7 @@ const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServ
const codexUsageService = new CodexUsageService(codexAppServerService);
const mcpTestService = new MCPTestService(settingsService);
const ideationService = new IdeationService(events, settingsService, featureLoader);
const providerUsageTracker = new ProviderUsageTracker(codexUsageService);
// Initialize DevServerService with event emitter for real-time log streaming
const devServerService = getDevServerService();
@@ -347,6 +350,7 @@ app.use('/api/pipeline', createPipelineRoutes(pipelineService));
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
app.use('/api/notifications', createNotificationsRoutes(notificationService));
app.use('/api/event-history', createEventHistoryRoutes(eventHistoryService, settingsService));
app.use('/api/provider-usage', createProviderUsageRoutes(providerUsageTracker));
// Create HTTP server
const server = createServer(app);

View File

@@ -0,0 +1,143 @@
/**
* Provider Usage Routes
*
* API endpoints for fetching usage data from all AI providers.
*
* Endpoints:
* - GET /api/provider-usage - Get usage for all enabled providers
* - GET /api/provider-usage/:providerId - Get usage for a specific provider
* - GET /api/provider-usage/availability - Check availability of all providers
*/
import { Router, Request, Response } from 'express';
import { createLogger } from '@automaker/utils';
import type { UsageProviderId } from '@automaker/types';
import { ProviderUsageTracker } from '../../services/provider-usage-tracker.js';
const logger = createLogger('ProviderUsageRoutes');
// Valid provider IDs
const VALID_PROVIDER_IDS: UsageProviderId[] = [
'claude',
'codex',
'cursor',
'gemini',
'copilot',
'opencode',
'minimax',
'glm',
];
export function createProviderUsageRoutes(tracker: ProviderUsageTracker): Router {
const router = Router();
/**
* GET /api/provider-usage
* Fetch usage for all enabled providers
*/
router.get('/', async (req: Request, res: Response) => {
try {
const forceRefresh = req.query.refresh === 'true';
const usage = await tracker.fetchAllUsage(forceRefresh);
res.json({
success: true,
data: usage,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error fetching all provider usage:', error);
res.status(500).json({
success: false,
error: message,
});
}
});
/**
* GET /api/provider-usage/availability
* Check which providers are available
*/
router.get('/availability', async (_req: Request, res: Response) => {
try {
const availability = await tracker.checkAvailability();
res.json({
success: true,
data: availability,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error checking provider availability:', error);
res.status(500).json({
success: false,
error: message,
});
}
});
/**
* GET /api/provider-usage/:providerId
* Fetch usage for a specific provider
*/
router.get('/:providerId', async (req: Request, res: Response) => {
try {
const providerId = req.params.providerId as UsageProviderId;
// Validate provider ID
if (!VALID_PROVIDER_IDS.includes(providerId)) {
res.status(400).json({
success: false,
error: `Invalid provider ID: ${providerId}. Valid providers: ${VALID_PROVIDER_IDS.join(', ')}`,
});
return;
}
// Check if provider is enabled
if (!tracker.isProviderEnabled(providerId)) {
res.status(200).json({
success: true,
data: {
providerId,
providerName: providerId,
available: false,
lastUpdated: new Date().toISOString(),
error: 'Provider is disabled',
},
});
return;
}
const forceRefresh = req.query.refresh === 'true';
const usage = await tracker.fetchProviderUsage(providerId, forceRefresh);
if (!usage) {
res.status(200).json({
success: true,
data: {
providerId,
providerName: providerId,
available: false,
lastUpdated: new Date().toISOString(),
error: 'Failed to fetch usage data',
},
});
return;
}
res.json({
success: true,
data: usage,
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error(`Error fetching usage for ${req.params.providerId}:`, error);
// Return 200 with error in data to avoid triggering logout
res.status(200).json({
success: false,
error: message,
});
}
});
return router;
}

View File

@@ -0,0 +1,288 @@
/**
* GitHub Copilot Usage Service
*
* Fetches usage data from GitHub's Copilot API using GitHub OAuth.
* Based on CodexBar reference implementation.
*
* Authentication methods:
* 1. GitHub CLI token (~/.config/gh/hosts.yml)
* 2. GitHub OAuth device flow (stored in config)
*
* API Endpoints:
* - GET https://api.github.com/copilot_internal/user - Quota and plan info
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { execSync } from 'child_process';
import { createLogger } from '@automaker/utils';
import type { CopilotProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('CopilotUsage');
// GitHub API endpoint for Copilot
const COPILOT_USER_ENDPOINT = 'https://api.github.com/copilot_internal/user';
interface CopilotQuotaSnapshot {
percentageUsed?: number;
percentageRemaining?: number;
limit?: number;
used?: number;
}
interface CopilotUserResponse {
copilotPlan?: string;
copilot_plan?: string;
quotaSnapshots?: {
premiumInteractions?: CopilotQuotaSnapshot;
chat?: CopilotQuotaSnapshot;
};
plan?: string;
}
export class CopilotUsageService {
private cachedToken: string | null = null;
/**
* Check if GitHub Copilot credentials are available
*/
async isAvailable(): Promise<boolean> {
const token = await this.getGitHubToken();
return !!token;
}
/**
* Get GitHub token from various sources
*/
private async getGitHubToken(): Promise<string | null> {
if (this.cachedToken) {
return this.cachedToken;
}
// 1. Check environment variable
if (process.env.GITHUB_TOKEN) {
this.cachedToken = process.env.GITHUB_TOKEN;
return this.cachedToken;
}
// 2. Check GH_TOKEN (GitHub CLI uses this)
if (process.env.GH_TOKEN) {
this.cachedToken = process.env.GH_TOKEN;
return this.cachedToken;
}
// 3. Try to get token from GitHub CLI
try {
const token = execSync('gh auth token', {
encoding: 'utf8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
if (token) {
this.cachedToken = token;
return this.cachedToken;
}
} catch {
logger.debug('Failed to get token from gh CLI');
}
// 4. Check GitHub CLI hosts.yml file
const ghHostsPath = path.join(os.homedir(), '.config', 'gh', 'hosts.yml');
if (fs.existsSync(ghHostsPath)) {
try {
const content = fs.readFileSync(ghHostsPath, 'utf8');
// Simple YAML parsing for oauth_token
const match = content.match(/oauth_token:\s*(.+)/);
if (match) {
this.cachedToken = match[1].trim();
return this.cachedToken;
}
} catch (error) {
logger.debug('Failed to read gh hosts.yml:', error);
}
}
// 5. Check CodexBar config (for users who also use CodexBar)
const codexbarConfigPath = path.join(os.homedir(), '.codexbar', 'config.json');
if (fs.existsSync(codexbarConfigPath)) {
try {
const content = fs.readFileSync(codexbarConfigPath, 'utf8');
const config = JSON.parse(content);
if (config.github?.oauth_token) {
this.cachedToken = config.github.oauth_token;
return this.cachedToken;
}
} catch (error) {
logger.debug('Failed to read CodexBar config:', error);
}
}
return null;
}
/**
* Make an authenticated request to GitHub Copilot API
*/
private async makeRequest<T>(url: string): Promise<T | null> {
const token = await this.getGitHubToken();
if (!token) {
return null;
}
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `token ${token}`,
Accept: 'application/json',
'User-Agent': 'automaker/1.0',
// Copilot-specific headers (from CodexBar reference)
'Editor-Version': 'vscode/1.96.2',
'Editor-Plugin-Version': 'copilot-chat/0.26.7',
'X-Github-Api-Version': '2025-04-01',
},
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Clear cached token on auth failure
this.cachedToken = null;
logger.warn('GitHub Copilot API authentication failed');
return null;
}
if (response.status === 404) {
// User may not have Copilot access
logger.info('GitHub Copilot not available for this user');
return null;
}
logger.error(`GitHub Copilot API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from GitHub Copilot API:', error);
return null;
}
}
/**
* Fetch usage data from GitHub Copilot
*/
async fetchUsageData(): Promise<CopilotProviderUsage> {
logger.info('[fetchUsageData] Starting GitHub Copilot usage fetch...');
const baseUsage: CopilotProviderUsage = {
providerId: 'copilot',
providerName: 'GitHub Copilot',
available: false,
lastUpdated: new Date().toISOString(),
};
// Check if token is available
const hasToken = await this.getGitHubToken();
if (!hasToken) {
baseUsage.error = 'GitHub authentication not available';
return baseUsage;
}
// Fetch Copilot user data
const userResponse = await this.makeRequest<CopilotUserResponse>(COPILOT_USER_ENDPOINT);
if (!userResponse) {
baseUsage.error = 'Failed to fetch GitHub Copilot usage data';
return baseUsage;
}
baseUsage.available = true;
// Parse quota snapshots
const quotas = userResponse.quotaSnapshots;
if (quotas) {
// Premium interactions quota
if (quotas.premiumInteractions) {
const premium = quotas.premiumInteractions;
const usedPercent =
premium.percentageUsed !== undefined
? premium.percentageUsed
: premium.percentageRemaining !== undefined
? 100 - premium.percentageRemaining
: 0;
const premiumWindow: UsageWindow = {
name: 'Premium Interactions',
usedPercent,
resetsAt: '', // GitHub doesn't provide reset time
resetText: 'Resets monthly',
limit: premium.limit,
used: premium.used,
};
baseUsage.primary = premiumWindow;
baseUsage.premiumInteractions = premiumWindow;
}
// Chat quota
if (quotas.chat) {
const chat = quotas.chat;
const usedPercent =
chat.percentageUsed !== undefined
? chat.percentageUsed
: chat.percentageRemaining !== undefined
? 100 - chat.percentageRemaining
: 0;
const chatWindow: UsageWindow = {
name: 'Chat',
usedPercent,
resetsAt: '',
resetText: 'Resets monthly',
limit: chat.limit,
used: chat.used,
};
baseUsage.secondary = chatWindow;
baseUsage.chatQuota = chatWindow;
}
}
// Parse plan type
const planType = userResponse.copilotPlan || userResponse.copilot_plan || userResponse.plan;
if (planType) {
baseUsage.copilotPlan = planType;
baseUsage.plan = {
type: planType,
displayName: this.formatPlanName(planType),
isPaid: planType.toLowerCase() !== 'free',
};
}
logger.info(
`[fetchUsageData] ✓ GitHub Copilot usage: Premium=${baseUsage.premiumInteractions?.usedPercent || 0}%, ` +
`Chat=${baseUsage.chatQuota?.usedPercent || 0}%, Plan=${planType || 'unknown'}`
);
return baseUsage;
}
/**
* Format plan name for display
*/
private formatPlanName(plan: string): string {
const planMap: Record<string, string> = {
free: 'Free',
individual: 'Individual',
business: 'Business',
enterprise: 'Enterprise',
};
return planMap[plan.toLowerCase()] || plan;
}
/**
* Clear cached token
*/
clearCache(): void {
this.cachedToken = null;
}
}

View File

@@ -0,0 +1,331 @@
/**
* Cursor Usage Service
*
* Fetches usage data from Cursor's API using session cookies or access token.
* Based on CodexBar reference implementation.
*
* Authentication methods (in priority order):
* 1. Cached session cookie from browser import
* 2. Access token from credentials file
*
* API Endpoints:
* - GET https://cursor.com/api/usage-summary - Plan usage, on-demand, billing dates
* - GET https://cursor.com/api/auth/me - User email and name
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type { CursorProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('CursorUsage');
// Cursor API endpoints
const CURSOR_API_BASE = 'https://cursor.com/api';
const USAGE_SUMMARY_ENDPOINT = `${CURSOR_API_BASE}/usage-summary`;
const AUTH_ME_ENDPOINT = `${CURSOR_API_BASE}/auth/me`;
// Session cookie names used by Cursor
const SESSION_COOKIE_NAMES = [
'WorkosCursorSessionToken',
'__Secure-next-auth.session-token',
'next-auth.session-token',
];
interface CursorUsageSummary {
planUsage?: {
percent: number;
resetAt?: string;
};
onDemandUsage?: {
percent: number;
costUsd?: number;
};
billingCycleEnd?: string;
plan?: string;
}
interface CursorAuthMe {
email?: string;
name?: string;
plan?: string;
}
export class CursorUsageService {
private cachedSessionCookie: string | null = null;
private cachedAccessToken: string | null = null;
/**
* Check if Cursor credentials are available
*/
async isAvailable(): Promise<boolean> {
return await this.hasValidCredentials();
}
/**
* Check if we have valid Cursor credentials
*/
private async hasValidCredentials(): Promise<boolean> {
const token = await this.getAccessToken();
return !!token;
}
/**
* Get access token from credentials file
*/
private async getAccessToken(): Promise<string | null> {
if (this.cachedAccessToken) {
return this.cachedAccessToken;
}
// Check environment variable first
if (process.env.CURSOR_ACCESS_TOKEN) {
this.cachedAccessToken = process.env.CURSOR_ACCESS_TOKEN;
return this.cachedAccessToken;
}
// Check credentials files
const credentialPaths = [
path.join(os.homedir(), '.cursor', 'credentials.json'),
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
];
for (const credPath of credentialPaths) {
try {
if (fs.existsSync(credPath)) {
const content = fs.readFileSync(credPath, 'utf8');
const creds = JSON.parse(content);
if (creds.accessToken) {
this.cachedAccessToken = creds.accessToken;
return this.cachedAccessToken;
}
if (creds.token) {
this.cachedAccessToken = creds.token;
return this.cachedAccessToken;
}
}
} catch (error) {
logger.debug(`Failed to read credentials from ${credPath}:`, error);
}
}
return null;
}
/**
* Get session cookie for API calls
* Returns a cookie string like "WorkosCursorSessionToken=xxx"
*/
private async getSessionCookie(): Promise<string | null> {
if (this.cachedSessionCookie) {
return this.cachedSessionCookie;
}
// Check for cookie in environment
if (process.env.CURSOR_SESSION_COOKIE) {
this.cachedSessionCookie = process.env.CURSOR_SESSION_COOKIE;
return this.cachedSessionCookie;
}
// Check for saved session file
const sessionPath = path.join(os.homedir(), '.cursor', 'session.json');
try {
if (fs.existsSync(sessionPath)) {
const content = fs.readFileSync(sessionPath, 'utf8');
const session = JSON.parse(content);
for (const cookieName of SESSION_COOKIE_NAMES) {
if (session[cookieName]) {
this.cachedSessionCookie = `${cookieName}=${session[cookieName]}`;
return this.cachedSessionCookie;
}
}
}
} catch (error) {
logger.debug('Failed to read session file:', error);
}
return null;
}
/**
* Make an authenticated request to Cursor API
*/
private async makeRequest<T>(url: string): Promise<T | null> {
const headers: Record<string, string> = {
Accept: 'application/json',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
};
// Try access token first
const accessToken = await this.getAccessToken();
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
// Try session cookie as fallback
const sessionCookie = await this.getSessionCookie();
if (sessionCookie) {
headers['Cookie'] = sessionCookie;
}
if (!accessToken && !sessionCookie) {
logger.warn('No Cursor credentials available for API request');
return null;
}
try {
const response = await fetch(url, {
method: 'GET',
headers,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Clear cached credentials on auth failure
this.cachedAccessToken = null;
this.cachedSessionCookie = null;
logger.warn('Cursor API authentication failed');
return null;
}
logger.error(`Cursor API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from Cursor API:', error);
return null;
}
}
/**
* Fetch usage data from Cursor
*/
async fetchUsageData(): Promise<CursorProviderUsage> {
logger.info('[fetchUsageData] Starting Cursor usage fetch...');
const baseUsage: CursorProviderUsage = {
providerId: 'cursor',
providerName: 'Cursor',
available: false,
lastUpdated: new Date().toISOString(),
};
// Check if credentials are available
const hasCredentials = await this.hasValidCredentials();
if (!hasCredentials) {
baseUsage.error = 'Cursor credentials not available';
return baseUsage;
}
// Fetch usage summary
const usageSummary = await this.makeRequest<CursorUsageSummary>(USAGE_SUMMARY_ENDPOINT);
if (!usageSummary) {
baseUsage.error = 'Failed to fetch Cursor usage data';
return baseUsage;
}
baseUsage.available = true;
// Parse plan usage
if (usageSummary.planUsage) {
const planWindow: UsageWindow = {
name: 'Plan Usage',
usedPercent: usageSummary.planUsage.percent || 0,
resetsAt: usageSummary.planUsage.resetAt || '',
resetText: usageSummary.planUsage.resetAt
? this.formatResetTime(usageSummary.planUsage.resetAt)
: '',
};
baseUsage.primary = planWindow;
baseUsage.planUsage = planWindow;
}
// Parse on-demand usage
if (usageSummary.onDemandUsage) {
const onDemandWindow: UsageWindow = {
name: 'On-Demand Usage',
usedPercent: usageSummary.onDemandUsage.percent || 0,
resetsAt: usageSummary.billingCycleEnd || '',
resetText: usageSummary.billingCycleEnd
? this.formatResetTime(usageSummary.billingCycleEnd)
: '',
};
baseUsage.secondary = onDemandWindow;
baseUsage.onDemandUsage = onDemandWindow;
if (usageSummary.onDemandUsage.costUsd !== undefined) {
baseUsage.onDemandCostUsd = usageSummary.onDemandUsage.costUsd;
}
}
// Parse billing cycle end
if (usageSummary.billingCycleEnd) {
baseUsage.billingCycleEnd = usageSummary.billingCycleEnd;
}
// Parse plan type
if (usageSummary.plan) {
baseUsage.plan = {
type: usageSummary.plan,
displayName: this.formatPlanName(usageSummary.plan),
isPaid: usageSummary.plan.toLowerCase() !== 'free',
};
}
logger.info(
`[fetchUsageData] ✓ Cursor usage: Plan=${baseUsage.planUsage?.usedPercent || 0}%, ` +
`OnDemand=${baseUsage.onDemandUsage?.usedPercent || 0}%`
);
return baseUsage;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAt: string): string {
try {
const date = new Date(resetAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const hours = Math.floor(diff / 3600000);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d`;
}
if (hours > 0) {
return `Resets in ${hours}h`;
}
return 'Resets soon';
} catch {
return '';
}
}
/**
* Format plan name for display
*/
private formatPlanName(plan: string): string {
const planMap: Record<string, string> = {
free: 'Free',
pro: 'Pro',
business: 'Business',
enterprise: 'Enterprise',
};
return planMap[plan.toLowerCase()] || plan;
}
/**
* Clear cached credentials (useful for logout)
*/
clearCache(): void {
this.cachedAccessToken = null;
this.cachedSessionCookie = null;
}
}

View File

@@ -0,0 +1,362 @@
/**
* Gemini Usage Service
*
* Fetches usage data from Google's Gemini/Cloud Code API using OAuth credentials.
* Based on CodexBar reference implementation.
*
* Authentication methods:
* 1. OAuth credentials from ~/.gemini/oauth_creds.json
* 2. API key (limited - only supports API calls, not quota info)
*
* API Endpoints:
* - POST https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota - Quota info
* - POST https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist - Tier detection
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type { GeminiProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('GeminiUsage');
// Gemini API endpoints
const QUOTA_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
const CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
const TOKEN_REFRESH_ENDPOINT = 'https://oauth2.googleapis.com/token';
// Gemini CLI client credentials (from Gemini CLI installation)
// These are embedded in the Gemini CLI and are public
const GEMINI_CLIENT_ID =
'764086051850-6qr4p6gpi6hn506pt8ejuq83di341hur.apps.googleusercontent.com';
const GEMINI_CLIENT_SECRET = 'd-FL95Q19q7MQmFpd7hHD0Ty';
interface GeminiOAuthCreds {
access_token: string;
refresh_token: string;
id_token?: string;
expiry_date: number;
}
interface GeminiQuotaResponse {
quotas?: Array<{
remainingFraction: number;
resetTime: string;
modelId?: string;
}>;
}
interface GeminiCodeAssistResponse {
tier?: string;
claims?: {
hd?: string;
};
}
export class GeminiUsageService {
private cachedCreds: GeminiOAuthCreds | null = null;
private settingsPath = path.join(os.homedir(), '.gemini', 'settings.json');
private credsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
/**
* Check if Gemini credentials are available
*/
async isAvailable(): Promise<boolean> {
const creds = await this.getOAuthCreds();
return !!creds;
}
/**
* Get authentication type from settings
*/
private getAuthType(): string | null {
try {
if (fs.existsSync(this.settingsPath)) {
const content = fs.readFileSync(this.settingsPath, 'utf8');
const settings = JSON.parse(content);
return settings.auth_type || settings.authType || null;
}
} catch (error) {
logger.debug('Failed to read Gemini settings:', error);
}
return null;
}
/**
* Get OAuth credentials from file
*/
private async getOAuthCreds(): Promise<GeminiOAuthCreds | null> {
// Check auth type - only oauth-personal supports quota API
const authType = this.getAuthType();
if (authType && authType !== 'oauth-personal') {
logger.debug(`Gemini auth type is ${authType}, not oauth-personal - quota API not available`);
return null;
}
// Check cached credentials
if (this.cachedCreds) {
// Check if expired
if (this.cachedCreds.expiry_date > Date.now()) {
return this.cachedCreds;
}
// Try to refresh
const refreshed = await this.refreshToken(this.cachedCreds.refresh_token);
if (refreshed) {
this.cachedCreds = refreshed;
return this.cachedCreds;
}
}
// Load from file
try {
if (fs.existsSync(this.credsPath)) {
const content = fs.readFileSync(this.credsPath, 'utf8');
const creds = JSON.parse(content) as GeminiOAuthCreds;
// Check if expired
if (creds.expiry_date && creds.expiry_date <= Date.now()) {
// Try to refresh
if (creds.refresh_token) {
const refreshed = await this.refreshToken(creds.refresh_token);
if (refreshed) {
this.cachedCreds = refreshed;
// Save refreshed credentials
this.saveCreds(refreshed);
return this.cachedCreds;
}
}
logger.warn('Gemini OAuth token expired and refresh failed');
return null;
}
this.cachedCreds = creds;
return this.cachedCreds;
}
} catch (error) {
logger.debug('Failed to read Gemini OAuth credentials:', error);
}
return null;
}
/**
* Refresh OAuth token
*/
private async refreshToken(refreshToken: string): Promise<GeminiOAuthCreds | null> {
try {
const response = await fetch(TOKEN_REFRESH_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: GEMINI_CLIENT_ID,
client_secret: GEMINI_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}),
});
if (!response.ok) {
logger.error(`Token refresh failed: ${response.status}`);
return null;
}
const data = (await response.json()) as {
access_token: string;
expires_in: number;
id_token?: string;
};
return {
access_token: data.access_token,
refresh_token: refreshToken,
id_token: data.id_token,
expiry_date: Date.now() + data.expires_in * 1000,
};
} catch (error) {
logger.error('Failed to refresh Gemini token:', error);
return null;
}
}
/**
* Save credentials to file
*/
private saveCreds(creds: GeminiOAuthCreds): void {
try {
const dir = path.dirname(this.credsPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.credsPath, JSON.stringify(creds, null, 2));
} catch (error) {
logger.warn('Failed to save Gemini credentials:', error);
}
}
/**
* Make an authenticated request to Gemini API
*/
private async makeRequest<T>(url: string, body?: unknown): Promise<T | null> {
const creds = await this.getOAuthCreds();
if (!creds) {
return null;
}
try {
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${creds.access_token}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
// Clear cached credentials on auth failure
this.cachedCreds = null;
logger.warn('Gemini API authentication failed');
return null;
}
logger.error(`Gemini API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from Gemini API:', error);
return null;
}
}
/**
* Fetch usage data from Gemini
*/
async fetchUsageData(): Promise<GeminiProviderUsage> {
logger.info('[fetchUsageData] Starting Gemini usage fetch...');
const baseUsage: GeminiProviderUsage = {
providerId: 'gemini',
providerName: 'Gemini',
available: false,
lastUpdated: new Date().toISOString(),
};
// Check if credentials are available
const creds = await this.getOAuthCreds();
if (!creds) {
baseUsage.error = 'Gemini OAuth credentials not available';
return baseUsage;
}
// Fetch quota information
const quotaResponse = await this.makeRequest<GeminiQuotaResponse>(QUOTA_ENDPOINT, {
projectId: '-', // Use default project
});
if (quotaResponse?.quotas && quotaResponse.quotas.length > 0) {
baseUsage.available = true;
const primaryQuota = quotaResponse.quotas[0];
// Convert remaining fraction to used percent
const usedPercent = Math.round((1 - (primaryQuota.remainingFraction || 0)) * 100);
const quotaWindow: UsageWindow = {
name: 'Quota',
usedPercent,
resetsAt: primaryQuota.resetTime || '',
resetText: primaryQuota.resetTime ? this.formatResetTime(primaryQuota.resetTime) : '',
};
baseUsage.primary = quotaWindow;
baseUsage.remainingFraction = primaryQuota.remainingFraction;
baseUsage.modelId = primaryQuota.modelId;
}
// Fetch tier information
const codeAssistResponse = await this.makeRequest<GeminiCodeAssistResponse>(
CODE_ASSIST_ENDPOINT,
{
metadata: {
ide: 'automaker',
},
}
);
if (codeAssistResponse?.tier) {
baseUsage.tierType = codeAssistResponse.tier;
// Determine plan info from tier
const tierMap: Record<string, { type: string; displayName: string; isPaid: boolean }> = {
'standard-tier': { type: 'paid', displayName: 'Paid', isPaid: true },
'free-tier': {
type: codeAssistResponse.claims?.hd ? 'workspace' : 'free',
displayName: codeAssistResponse.claims?.hd ? 'Workspace' : 'Free',
isPaid: false,
},
'legacy-tier': { type: 'legacy', displayName: 'Legacy', isPaid: false },
};
const tierInfo = tierMap[codeAssistResponse.tier] || {
type: codeAssistResponse.tier,
displayName: codeAssistResponse.tier,
isPaid: false,
};
baseUsage.plan = tierInfo;
}
if (baseUsage.available) {
logger.info(
`[fetchUsageData] ✓ Gemini usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
`tier=${baseUsage.tierType || 'unknown'}`
);
} else {
baseUsage.error = 'Failed to fetch Gemini quota data';
}
return baseUsage;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAt: string): string {
try {
const date = new Date(resetAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `Resets in ${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `Resets in ${minutes}m`;
}
return 'Resets soon';
} catch {
return '';
}
}
/**
* Clear cached credentials
*/
clearCache(): void {
this.cachedCreds = null;
}
}

View File

@@ -0,0 +1,140 @@
/**
* GLM (z.AI) Usage Service
*
* Fetches usage data from z.AI's API.
* GLM is a Claude-compatible provider offered by z.AI.
*
* Authentication:
* - API Token from provider config or GLM_API_KEY environment variable
*
* Note: z.AI's API may not expose a dedicated usage endpoint.
* This service checks for API availability and reports basic status.
*/
import { createLogger } from '@automaker/utils';
import type { GLMProviderUsage, ClaudeCompatibleProvider } from '@automaker/types';
const logger = createLogger('GLMUsage');
// GLM API base (z.AI)
const GLM_API_BASE = 'https://api.z.ai';
export class GLMUsageService {
private providerConfig: ClaudeCompatibleProvider | null = null;
private cachedApiKey: string | null = null;
/**
* Set the provider config (called from settings)
*/
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
this.providerConfig = config;
this.cachedApiKey = null;
}
/**
* Check if GLM is available
*/
async isAvailable(): Promise<boolean> {
const apiKey = this.getApiKey();
return !!apiKey;
}
/**
* Get API key from various sources
*/
private getApiKey(): string | null {
if (this.cachedApiKey) {
return this.cachedApiKey;
}
// 1. Check environment variable
if (process.env.GLM_API_KEY) {
this.cachedApiKey = process.env.GLM_API_KEY;
return this.cachedApiKey;
}
// 2. Check provider config
if (this.providerConfig?.apiKey) {
this.cachedApiKey = this.providerConfig.apiKey;
return this.cachedApiKey;
}
return null;
}
/**
* Fetch usage data from GLM
*
* Note: z.AI may not have a public usage API.
* This returns basic availability status.
*/
async fetchUsageData(): Promise<GLMProviderUsage> {
logger.info('[fetchUsageData] Starting GLM usage fetch...');
const baseUsage: GLMProviderUsage = {
providerId: 'glm',
providerName: 'z.AI GLM',
available: false,
lastUpdated: new Date().toISOString(),
};
const apiKey = this.getApiKey();
if (!apiKey) {
baseUsage.error = 'GLM API key not available';
return baseUsage;
}
// GLM/z.AI is available if we have an API key
// z.AI doesn't appear to have a public usage endpoint
baseUsage.available = true;
// Check if API key is valid by making a simple request
try {
const baseUrl = this.providerConfig?.baseUrl || GLM_API_BASE;
const response = await fetch(`${baseUrl}/api/anthropic/v1/messages`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: 'GLM-4.7',
max_tokens: 1,
messages: [{ role: 'user', content: 'hi' }],
}),
});
// We just want to check if auth works, not actually make a request
// A 400 with invalid request is fine - it means auth worked
if (response.status === 401 || response.status === 403) {
baseUsage.available = false;
baseUsage.error = 'GLM API authentication failed';
}
} catch (error) {
// Network error or other issue - still mark as available since we have the key
logger.debug('GLM API check failed (may be fine):', error);
}
// Note: z.AI doesn't appear to expose usage metrics via API
// Users should check their z.AI dashboard for detailed usage
if (baseUsage.available) {
baseUsage.plan = {
type: 'api',
displayName: 'API Access',
isPaid: true,
};
}
logger.info(`[fetchUsageData] GLM available: ${baseUsage.available}`);
return baseUsage;
}
/**
* Clear cached credentials
*/
clearCache(): void {
this.cachedApiKey = null;
}
}

View File

@@ -0,0 +1,260 @@
/**
* MiniMax Usage Service
*
* Fetches usage data from MiniMax's coding plan API.
* Based on CodexBar reference implementation.
*
* Authentication methods:
* 1. API Token (MINIMAX_API_KEY environment variable or provider config)
* 2. Cookie-based authentication (from platform login)
*
* API Endpoints:
* - GET https://api.minimax.io/v1/coding_plan/remains - Token-based usage
* - GET https://platform.minimax.io/v1/api/openplatform/coding_plan/remains - Fallback
*
* For China mainland: platform.minimaxi.com
*/
import { createLogger } from '@automaker/utils';
import type { MiniMaxProviderUsage, UsageWindow, ClaudeCompatibleProvider } from '@automaker/types';
const logger = createLogger('MiniMaxUsage');
// MiniMax API endpoints
const MINIMAX_API_BASE = 'https://api.minimax.io';
const MINIMAX_PLATFORM_BASE = 'https://platform.minimax.io';
const MINIMAX_CHINA_BASE = 'https://platform.minimaxi.com';
const CODING_PLAN_ENDPOINT = '/v1/coding_plan/remains';
const PLATFORM_CODING_PLAN_ENDPOINT = '/v1/api/openplatform/coding_plan/remains';
interface MiniMaxCodingPlanResponse {
base_resp?: {
status_code?: number;
status_msg?: string;
};
model_remains?: Array<{
model: string;
used: number;
total: number;
}>;
remains_time?: number; // Seconds until reset
start_time?: string;
end_time?: string;
}
export class MiniMaxUsageService {
private providerConfig: ClaudeCompatibleProvider | null = null;
private cachedApiKey: string | null = null;
/**
* Set the provider config (called from settings)
*/
setProviderConfig(config: ClaudeCompatibleProvider | null): void {
this.providerConfig = config;
this.cachedApiKey = null; // Clear cache when config changes
}
/**
* Check if MiniMax is available
*/
async isAvailable(): Promise<boolean> {
const apiKey = this.getApiKey();
return !!apiKey;
}
/**
* Get API key from various sources
*/
private getApiKey(): string | null {
if (this.cachedApiKey) {
return this.cachedApiKey;
}
// 1. Check environment variable
if (process.env.MINIMAX_API_KEY) {
this.cachedApiKey = process.env.MINIMAX_API_KEY;
return this.cachedApiKey;
}
// 2. Check provider config
if (this.providerConfig?.apiKey) {
this.cachedApiKey = this.providerConfig.apiKey;
return this.cachedApiKey;
}
return null;
}
/**
* Determine if we should use China endpoint
*/
private isChina(): boolean {
if (this.providerConfig?.baseUrl) {
return this.providerConfig.baseUrl.includes('minimaxi.com');
}
return false;
}
/**
* Make an authenticated request to MiniMax API
*/
private async makeRequest<T>(url: string): Promise<T | null> {
const apiKey = this.getApiKey();
if (!apiKey) {
return null;
}
try {
const response = await fetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
this.cachedApiKey = null;
logger.warn('MiniMax API authentication failed');
return null;
}
logger.error(`MiniMax API error: ${response.status} ${response.statusText}`);
return null;
}
return (await response.json()) as T;
} catch (error) {
logger.error('Failed to fetch from MiniMax API:', error);
return null;
}
}
/**
* Fetch usage data from MiniMax
*/
async fetchUsageData(): Promise<MiniMaxProviderUsage> {
logger.info('[fetchUsageData] Starting MiniMax usage fetch...');
const baseUsage: MiniMaxProviderUsage = {
providerId: 'minimax',
providerName: 'MiniMax',
available: false,
lastUpdated: new Date().toISOString(),
};
const apiKey = this.getApiKey();
if (!apiKey) {
baseUsage.error = 'MiniMax API key not available';
return baseUsage;
}
// Determine the correct endpoint
const isChina = this.isChina();
const baseUrl = isChina ? MINIMAX_CHINA_BASE : MINIMAX_API_BASE;
const endpoint = `${baseUrl}${CODING_PLAN_ENDPOINT}`;
// Fetch coding plan data
let codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(endpoint);
// Try fallback endpoint if primary fails
if (!codingPlan) {
const platformBase = isChina ? MINIMAX_CHINA_BASE : MINIMAX_PLATFORM_BASE;
const fallbackEndpoint = `${platformBase}${PLATFORM_CODING_PLAN_ENDPOINT}`;
codingPlan = await this.makeRequest<MiniMaxCodingPlanResponse>(fallbackEndpoint);
}
if (!codingPlan) {
baseUsage.error = 'Failed to fetch MiniMax usage data';
return baseUsage;
}
// Check for error response
if (codingPlan.base_resp?.status_code && codingPlan.base_resp.status_code !== 0) {
baseUsage.error = codingPlan.base_resp.status_msg || 'MiniMax API error';
return baseUsage;
}
baseUsage.available = true;
// Parse model remains
if (codingPlan.model_remains && codingPlan.model_remains.length > 0) {
let totalUsed = 0;
let totalLimit = 0;
for (const model of codingPlan.model_remains) {
totalUsed += model.used;
totalLimit += model.total;
}
const usedPercent = totalLimit > 0 ? Math.round((totalUsed / totalLimit) * 100) : 0;
// Calculate reset time
const resetsAt = codingPlan.remains_time
? new Date(Date.now() + codingPlan.remains_time * 1000).toISOString()
: codingPlan.end_time || '';
const usageWindow: UsageWindow = {
name: 'Coding Plan',
usedPercent,
resetsAt,
resetText: resetsAt ? this.formatResetTime(resetsAt) : '',
used: totalUsed,
limit: totalLimit,
};
baseUsage.primary = usageWindow;
baseUsage.tokenRemains = totalLimit - totalUsed;
baseUsage.totalTokens = totalLimit;
}
// Parse plan times
if (codingPlan.start_time) {
baseUsage.planStartTime = codingPlan.start_time;
}
if (codingPlan.end_time) {
baseUsage.planEndTime = codingPlan.end_time;
}
logger.info(
`[fetchUsageData] ✓ MiniMax usage: ${baseUsage.primary?.usedPercent || 0}% used, ` +
`${baseUsage.tokenRemains || 0} tokens remaining`
);
return baseUsage;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAt: string): string {
try {
const date = new Date(resetAt);
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const hours = Math.floor(diff / 3600000);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d`;
}
if (hours > 0) {
return `Resets in ${hours}h`;
}
return 'Resets soon';
} catch {
return '';
}
}
/**
* Clear cached credentials
*/
clearCache(): void {
this.cachedApiKey = null;
}
}

View File

@@ -0,0 +1,144 @@
/**
* OpenCode Usage Service
*
* Fetches usage data from OpenCode's server API.
* Based on CodexBar reference implementation.
*
* Note: OpenCode usage tracking is limited as they use a proprietary
* server function API that requires browser cookies for authentication.
* This service provides basic status checking based on local config.
*
* API Endpoints (require browser cookies):
* - POST https://opencode.ai/_server - Server functions
* - workspaces: Get workspace info
* - subscription.get: Get usage data
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { createLogger } from '@automaker/utils';
import type { OpenCodeProviderUsage, UsageWindow } from '@automaker/types';
const logger = createLogger('OpenCodeUsage');
// OpenCode config locations
const OPENCODE_CONFIG_PATHS = [
path.join(os.homedir(), '.opencode', 'config.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
];
interface OpenCodeConfig {
workspaceId?: string;
email?: string;
authenticated?: boolean;
}
interface OpenCodeUsageData {
rollingUsage?: {
usagePercent: number;
resetInSec: number;
};
weeklyUsage?: {
usagePercent: number;
resetInSec: number;
};
}
export class OpenCodeUsageService {
private cachedConfig: OpenCodeConfig | null = null;
/**
* Check if OpenCode is available
*/
async isAvailable(): Promise<boolean> {
const config = this.getConfig();
return !!config?.authenticated;
}
/**
* Get OpenCode config from disk
*/
private getConfig(): OpenCodeConfig | null {
if (this.cachedConfig) {
return this.cachedConfig;
}
// Check environment variable for workspace ID
if (process.env.OPENCODE_WORKSPACE_ID) {
this.cachedConfig = {
workspaceId: process.env.OPENCODE_WORKSPACE_ID,
authenticated: true,
};
return this.cachedConfig;
}
// Check config files
for (const configPath of OPENCODE_CONFIG_PATHS) {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, 'utf8');
const config = JSON.parse(content) as OpenCodeConfig;
this.cachedConfig = config;
return this.cachedConfig;
}
} catch (error) {
logger.debug(`Failed to read OpenCode config from ${configPath}:`, error);
}
}
return null;
}
/**
* Fetch usage data from OpenCode
*
* Note: OpenCode's usage API requires browser cookies which we don't have access to.
* This implementation returns basic availability status.
* For full usage tracking, users should check the OpenCode dashboard.
*/
async fetchUsageData(): Promise<OpenCodeProviderUsage> {
logger.info('[fetchUsageData] Starting OpenCode usage fetch...');
const baseUsage: OpenCodeProviderUsage = {
providerId: 'opencode',
providerName: 'OpenCode',
available: false,
lastUpdated: new Date().toISOString(),
};
const config = this.getConfig();
if (!config) {
baseUsage.error = 'OpenCode not configured';
return baseUsage;
}
if (!config.authenticated) {
baseUsage.error = 'OpenCode not authenticated';
return baseUsage;
}
// OpenCode is available but we can't get detailed usage without browser cookies
baseUsage.available = true;
baseUsage.workspaceId = config.workspaceId;
// Note: Full usage tracking requires browser cookie authentication
// which is not available in a server-side context.
// Users should check the OpenCode dashboard for detailed usage.
baseUsage.error =
'Usage details require browser authentication. Check opencode.ai for details.';
logger.info(
`[fetchUsageData] OpenCode available, workspace: ${config.workspaceId || 'unknown'}`
);
return baseUsage;
}
/**
* Clear cached config
*/
clearCache(): void {
this.cachedConfig = null;
}
}

View File

@@ -0,0 +1,447 @@
/**
* Provider Usage Tracker
*
* Unified service that aggregates usage data from all supported AI providers.
* Manages caching, polling, and coordination of individual usage services.
*
* Supported providers:
* - Claude (via ClaudeUsageService)
* - Codex (via CodexUsageService)
* - Cursor (via CursorUsageService)
* - Gemini (via GeminiUsageService)
* - GitHub Copilot (via CopilotUsageService)
* - OpenCode (via OpenCodeUsageService)
* - MiniMax (via MiniMaxUsageService)
* - GLM (via GLMUsageService)
*/
import { createLogger } from '@automaker/utils';
import type {
UsageProviderId,
ProviderUsage,
AllProvidersUsage,
ClaudeProviderUsage,
CodexProviderUsage,
ClaudeCompatibleProvider,
} from '@automaker/types';
import { ClaudeUsageService } from './claude-usage-service.js';
import { CodexUsageService, type CodexUsageData } from './codex-usage-service.js';
import { CursorUsageService } from './cursor-usage-service.js';
import { GeminiUsageService } from './gemini-usage-service.js';
import { CopilotUsageService } from './copilot-usage-service.js';
import { OpenCodeUsageService } from './opencode-usage-service.js';
import { MiniMaxUsageService } from './minimax-usage-service.js';
import { GLMUsageService } from './glm-usage-service.js';
import type { ClaudeUsage } from '../routes/claude/types.js';
const logger = createLogger('ProviderUsageTracker');
// Cache TTL in milliseconds (1 minute)
const CACHE_TTL_MS = 60 * 1000;
interface CachedUsage {
data: ProviderUsage;
fetchedAt: number;
}
export class ProviderUsageTracker {
private claudeService: ClaudeUsageService;
private codexService: CodexUsageService;
private cursorService: CursorUsageService;
private geminiService: GeminiUsageService;
private copilotService: CopilotUsageService;
private opencodeService: OpenCodeUsageService;
private minimaxService: MiniMaxUsageService;
private glmService: GLMUsageService;
private cache: Map<UsageProviderId, CachedUsage> = new Map();
private enabledProviders: Set<UsageProviderId> = new Set([
'claude',
'codex',
'cursor',
'gemini',
'copilot',
'opencode',
'minimax',
'glm',
]);
constructor(codexService?: CodexUsageService) {
this.claudeService = new ClaudeUsageService();
this.codexService = codexService || new CodexUsageService();
this.cursorService = new CursorUsageService();
this.geminiService = new GeminiUsageService();
this.copilotService = new CopilotUsageService();
this.opencodeService = new OpenCodeUsageService();
this.minimaxService = new MiniMaxUsageService();
this.glmService = new GLMUsageService();
}
/**
* Set enabled providers (called when settings change)
*/
setEnabledProviders(providers: UsageProviderId[]): void {
this.enabledProviders = new Set(providers);
}
/**
* Update custom provider configs (MiniMax, GLM)
*/
updateCustomProviderConfigs(providers: ClaudeCompatibleProvider[]): void {
const minimaxConfig = providers.find(
(p) => p.providerType === 'minimax' && p.enabled !== false
);
const glmConfig = providers.find((p) => p.providerType === 'glm' && p.enabled !== false);
this.minimaxService.setProviderConfig(minimaxConfig || null);
this.glmService.setProviderConfig(glmConfig || null);
}
/**
* Check if a provider is enabled
*/
isProviderEnabled(providerId: UsageProviderId): boolean {
return this.enabledProviders.has(providerId);
}
/**
* Check if cached data is still fresh
*/
private isCacheFresh(providerId: UsageProviderId): boolean {
const cached = this.cache.get(providerId);
if (!cached) return false;
return Date.now() - cached.fetchedAt < CACHE_TTL_MS;
}
/**
* Get cached data for a provider
*/
private getCached(providerId: UsageProviderId): ProviderUsage | null {
const cached = this.cache.get(providerId);
return cached?.data || null;
}
/**
* Set cached data for a provider
*/
private setCached(providerId: UsageProviderId, data: ProviderUsage): void {
this.cache.set(providerId, {
data,
fetchedAt: Date.now(),
});
}
/**
* Convert Claude usage to unified format
*/
private convertClaudeUsage(usage: ClaudeUsage): ClaudeProviderUsage {
return {
providerId: 'claude',
providerName: 'Claude',
available: true,
lastUpdated: usage.lastUpdated,
userTimezone: usage.userTimezone,
primary: {
name: 'Session (5-hour)',
usedPercent: usage.sessionPercentage,
resetsAt: usage.sessionResetTime,
resetText: usage.sessionResetText,
},
secondary: {
name: 'Weekly (All Models)',
usedPercent: usage.weeklyPercentage,
resetsAt: usage.weeklyResetTime,
resetText: usage.weeklyResetText,
},
sessionWindow: {
name: 'Session (5-hour)',
usedPercent: usage.sessionPercentage,
resetsAt: usage.sessionResetTime,
resetText: usage.sessionResetText,
},
weeklyWindow: {
name: 'Weekly (All Models)',
usedPercent: usage.weeklyPercentage,
resetsAt: usage.weeklyResetTime,
resetText: usage.weeklyResetText,
},
sonnetWindow: {
name: 'Weekly (Sonnet)',
usedPercent: usage.sonnetWeeklyPercentage,
resetsAt: usage.weeklyResetTime,
resetText: usage.sonnetResetText,
},
cost:
usage.costUsed !== null
? {
used: usage.costUsed,
limit: usage.costLimit,
currency: usage.costCurrency || 'USD',
}
: undefined,
};
}
/**
* Convert Codex usage to unified format
*/
private convertCodexUsage(usage: CodexUsageData): CodexProviderUsage {
const result: CodexProviderUsage = {
providerId: 'codex',
providerName: 'Codex',
available: true,
lastUpdated: usage.lastUpdated,
planType: usage.rateLimits?.planType,
};
if (usage.rateLimits?.primary) {
result.primary = {
name: `${usage.rateLimits.primary.windowDurationMins}min Window`,
usedPercent: usage.rateLimits.primary.usedPercent,
resetsAt: new Date(usage.rateLimits.primary.resetsAt * 1000).toISOString(),
resetText: this.formatResetTime(usage.rateLimits.primary.resetsAt * 1000),
windowDurationMins: usage.rateLimits.primary.windowDurationMins,
};
}
if (usage.rateLimits?.secondary) {
result.secondary = {
name: `${usage.rateLimits.secondary.windowDurationMins}min Window`,
usedPercent: usage.rateLimits.secondary.usedPercent,
resetsAt: new Date(usage.rateLimits.secondary.resetsAt * 1000).toISOString(),
resetText: this.formatResetTime(usage.rateLimits.secondary.resetsAt * 1000),
windowDurationMins: usage.rateLimits.secondary.windowDurationMins,
};
}
if (usage.rateLimits?.planType) {
result.plan = {
type: usage.rateLimits.planType,
displayName:
usage.rateLimits.planType.charAt(0).toUpperCase() + usage.rateLimits.planType.slice(1),
isPaid: usage.rateLimits.planType !== 'free',
};
}
return result;
}
/**
* Format reset time as human-readable string
*/
private formatResetTime(resetAtMs: number): string {
const diff = resetAtMs - Date.now();
if (diff < 0) return 'Expired';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `Resets in ${days}d ${hours % 24}h`;
if (hours > 0) return `Resets in ${hours}h ${minutes % 60}m`;
if (minutes > 0) return `Resets in ${minutes}m`;
return 'Resets soon';
}
/**
* Fetch usage for a specific provider
*/
async fetchProviderUsage(
providerId: UsageProviderId,
forceRefresh = false
): Promise<ProviderUsage | null> {
// Check cache first
if (!forceRefresh && this.isCacheFresh(providerId)) {
return this.getCached(providerId);
}
try {
let usage: ProviderUsage | null = null;
switch (providerId) {
case 'claude': {
if (await this.claudeService.isAvailable()) {
const claudeUsage = await this.claudeService.fetchUsageData();
usage = this.convertClaudeUsage(claudeUsage);
} else {
usage = {
providerId: 'claude',
providerName: 'Claude',
available: false,
lastUpdated: new Date().toISOString(),
error: 'Claude CLI not available',
};
}
break;
}
case 'codex': {
if (await this.codexService.isAvailable()) {
const codexUsage = await this.codexService.fetchUsageData();
usage = this.convertCodexUsage(codexUsage);
} else {
usage = {
providerId: 'codex',
providerName: 'Codex',
available: false,
lastUpdated: new Date().toISOString(),
error: 'Codex CLI not available',
};
}
break;
}
case 'cursor': {
usage = await this.cursorService.fetchUsageData();
break;
}
case 'gemini': {
usage = await this.geminiService.fetchUsageData();
break;
}
case 'copilot': {
usage = await this.copilotService.fetchUsageData();
break;
}
case 'opencode': {
usage = await this.opencodeService.fetchUsageData();
break;
}
case 'minimax': {
usage = await this.minimaxService.fetchUsageData();
break;
}
case 'glm': {
usage = await this.glmService.fetchUsageData();
break;
}
}
if (usage) {
this.setCached(providerId, usage);
}
return usage;
} catch (error) {
logger.error(`Failed to fetch usage for ${providerId}:`, error);
return {
providerId,
providerName: this.getProviderName(providerId),
available: false,
lastUpdated: new Date().toISOString(),
error: error instanceof Error ? error.message : 'Unknown error',
} as ProviderUsage;
}
}
/**
* Get provider display name
*/
private getProviderName(providerId: UsageProviderId): string {
const names: Record<UsageProviderId, string> = {
claude: 'Claude',
codex: 'Codex',
cursor: 'Cursor',
gemini: 'Gemini',
copilot: 'GitHub Copilot',
opencode: 'OpenCode',
minimax: 'MiniMax',
glm: 'z.AI GLM',
};
return names[providerId] || providerId;
}
/**
* Fetch usage for all enabled providers
*/
async fetchAllUsage(forceRefresh = false): Promise<AllProvidersUsage> {
const providers: Partial<Record<UsageProviderId, ProviderUsage>> = {};
const errors: Array<{ providerId: UsageProviderId; message: string }> = [];
// Fetch all enabled providers in parallel
const enabledList = Array.from(this.enabledProviders);
const results = await Promise.allSettled(
enabledList.map((providerId) => this.fetchProviderUsage(providerId, forceRefresh))
);
results.forEach((result, index) => {
const providerId = enabledList[index];
if (result.status === 'fulfilled' && result.value) {
providers[providerId] = result.value;
if (result.value.error) {
errors.push({
providerId,
message: result.value.error,
});
}
} else if (result.status === 'rejected') {
errors.push({
providerId,
message: result.reason?.message || 'Unknown error',
});
}
});
return {
providers,
lastUpdated: new Date().toISOString(),
errors,
};
}
/**
* Check availability for all providers
*/
async checkAvailability(): Promise<Record<UsageProviderId, boolean>> {
const availability: Record<string, boolean> = {};
const checks = await Promise.allSettled([
this.claudeService.isAvailable(),
this.codexService.isAvailable(),
this.cursorService.isAvailable(),
this.geminiService.isAvailable(),
this.copilotService.isAvailable(),
this.opencodeService.isAvailable(),
this.minimaxService.isAvailable(),
this.glmService.isAvailable(),
]);
const providerIds: UsageProviderId[] = [
'claude',
'codex',
'cursor',
'gemini',
'copilot',
'opencode',
'minimax',
'glm',
];
checks.forEach((result, index) => {
availability[providerIds[index]] =
result.status === 'fulfilled' ? result.value : false;
});
return availability as Record<UsageProviderId, boolean>;
}
/**
* Clear all caches
*/
clearCache(): void {
this.cache.clear();
this.claudeService = new ClaudeUsageService(); // Reset Claude service
this.cursorService.clearCache();
this.geminiService.clearCache();
this.copilotService.clearCache();
this.opencodeService.clearCache();
this.minimaxService.clearCache();
this.glmService.clearCache();
}
}

View File

@@ -0,0 +1,389 @@
/**
* Provider Usage Bar
*
* A compact usage bar that displays usage statistics for all enabled AI providers.
* Shows a unified view with individual provider usage indicators.
*/
import { useState, useMemo } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import {
AnthropicIcon,
OpenAIIcon,
CursorIcon,
GeminiIcon,
OpenCodeIcon,
MiniMaxIcon,
GlmIcon,
} from '@/components/ui/provider-icon';
import { useAllProvidersUsage } from '@/hooks/queries';
import type { UsageProviderId, ProviderUsage } from '@automaker/types';
import { getMaxUsagePercent } from '@automaker/types';
// GitHub icon component
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={cn('inline-block', className)} fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}
// Provider icon mapping
const PROVIDER_ICONS: Record<UsageProviderId, React.FC<{ className?: string }>> = {
claude: AnthropicIcon,
codex: OpenAIIcon,
cursor: CursorIcon,
gemini: GeminiIcon,
copilot: GitHubIcon,
opencode: OpenCodeIcon,
minimax: MiniMaxIcon,
glm: GlmIcon,
};
// Provider dashboard URLs
const PROVIDER_DASHBOARD_URLS: Record<UsageProviderId, string | undefined> = {
claude: 'https://status.claude.com',
codex: 'https://platform.openai.com/usage',
cursor: 'https://cursor.com/settings',
gemini: 'https://aistudio.google.com',
copilot: 'https://github.com/settings/copilot',
opencode: 'https://opencode.ai',
minimax: 'https://platform.minimax.io/user-center/payment/coding-plan',
glm: 'https://z.ai/account',
};
// Helper to get status color based on percentage
function getStatusInfo(percentage: number) {
if (percentage >= 90) return { color: 'text-red-500', icon: XCircle, bg: 'bg-red-500' };
if (percentage >= 75) return { color: 'text-orange-500', icon: AlertTriangle, bg: 'bg-orange-500' };
if (percentage >= 50) return { color: 'text-yellow-500', icon: AlertTriangle, bg: 'bg-yellow-500' };
return { color: 'text-green-500', icon: CheckCircle, bg: 'bg-green-500' };
}
// Progress bar component
function ProgressBar({ percentage, colorClass }: { percentage: number; colorClass: string }) {
return (
<div className="h-2 w-full bg-secondary/50 rounded-full overflow-hidden">
<div
className={cn('h-full transition-all duration-500', colorClass)}
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</div>
);
}
// Usage card component
function UsageCard({
title,
subtitle,
percentage,
resetText,
isPrimary = false,
stale = false,
}: {
title: string;
subtitle: string;
percentage: number;
resetText?: string;
isPrimary?: boolean;
stale?: boolean;
}) {
const isValidPercentage =
typeof percentage === 'number' && !isNaN(percentage) && isFinite(percentage);
const safePercentage = isValidPercentage ? percentage : 0;
const status = getStatusInfo(safePercentage);
const StatusIcon = status.icon;
return (
<div
className={cn(
'rounded-xl border bg-card/50 p-3 transition-opacity',
isPrimary ? 'border-border/60 shadow-sm' : 'border-border/40',
(stale || !isValidPercentage) && 'opacity-50'
)}
>
<div className="flex items-start justify-between mb-2">
<div>
<h4 className={cn('font-semibold', isPrimary ? 'text-sm' : 'text-xs')}>{title}</h4>
<p className="text-[10px] text-muted-foreground">{subtitle}</p>
</div>
{isValidPercentage ? (
<div className="flex items-center gap-1.5">
<StatusIcon className={cn('w-3.5 h-3.5', status.color)} />
<span
className={cn(
'font-mono font-bold',
status.color,
isPrimary ? 'text-base' : 'text-sm'
)}
>
{Math.round(safePercentage)}%
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">N/A</span>
)}
</div>
<ProgressBar
percentage={safePercentage}
colorClass={isValidPercentage ? status.bg : 'bg-muted-foreground/30'}
/>
{resetText && (
<div className="mt-1.5 flex justify-end">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Clock className="w-2.5 h-2.5" />
{resetText}
</p>
</div>
)}
</div>
);
}
// Provider usage panel component
function ProviderUsagePanel({
providerId,
usage,
isStale,
}: {
providerId: UsageProviderId;
usage: ProviderUsage;
isStale: boolean;
}) {
const ProviderIcon = PROVIDER_ICONS[providerId];
const dashboardUrl = PROVIDER_DASHBOARD_URLS[providerId];
if (!usage.available) {
return (
<div className="p-3 space-y-2">
<div className="flex items-center gap-2">
<ProviderIcon className="w-4 h-4" />
<span className="text-sm font-medium">{usage.providerName}</span>
</div>
<div className="flex flex-col items-center justify-center py-4 text-center space-y-2">
<AlertTriangle className="w-6 h-6 text-yellow-500/80" />
<p className="text-xs text-muted-foreground">
{usage.error || 'Not available'}
</p>
</div>
</div>
);
}
return (
<div className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ProviderIcon className="w-4 h-4" />
<span className="text-sm font-medium">{usage.providerName}</span>
</div>
{usage.plan && (
<span className="text-[10px] px-1.5 py-0.5 bg-secondary rounded text-muted-foreground">
{usage.plan.displayName}
</span>
)}
</div>
{usage.primary && (
<UsageCard
title={usage.primary.name}
subtitle={usage.primary.windowDurationMins ? `${usage.primary.windowDurationMins}min window` : 'Usage quota'}
percentage={usage.primary.usedPercent}
resetText={usage.primary.resetText}
isPrimary={true}
stale={isStale}
/>
)}
{usage.secondary && (
<UsageCard
title={usage.secondary.name}
subtitle={usage.secondary.windowDurationMins ? `${usage.secondary.windowDurationMins}min window` : 'Usage quota'}
percentage={usage.secondary.usedPercent}
resetText={usage.secondary.resetText}
stale={isStale}
/>
)}
{!usage.primary && !usage.secondary && (
<div className="text-xs text-muted-foreground text-center py-2">
{dashboardUrl ? (
<>
Check{' '}
<a
href={dashboardUrl}
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
dashboard
</a>{' '}
for details
</>
) : (
'No usage data available'
)}
</div>
)}
</div>
);
}
export function ProviderUsageBar() {
const [open, setOpen] = useState(false);
const {
data: allUsage,
isLoading,
error,
dataUpdatedAt,
refetch,
} = useAllProvidersUsage(open);
// Calculate overall max usage percentage
const { maxPercent, maxProviderId, availableCount } = useMemo(() => {
if (!allUsage?.providers) {
return { maxPercent: 0, maxProviderId: null as UsageProviderId | null, availableCount: 0 };
}
let max = 0;
let maxId: UsageProviderId | null = null;
let count = 0;
for (const [id, usage] of Object.entries(allUsage.providers)) {
if (usage?.available) {
count++;
const percent = getMaxUsagePercent(usage);
if (percent > max) {
max = percent;
maxId = id as UsageProviderId;
}
}
}
return { maxPercent: max, maxProviderId: maxId, availableCount: count };
}, [allUsage]);
// Check if data is stale (older than 2 minutes)
const isStale = !dataUpdatedAt || Date.now() - dataUpdatedAt > 2 * 60 * 1000;
const getProgressBarColor = (percentage: number) => {
if (percentage >= 90) return 'bg-red-500';
if (percentage >= 75) return 'bg-orange-500';
if (percentage >= 50) return 'bg-yellow-500';
return 'bg-green-500';
};
// Get the icon for the provider with highest usage
const MaxProviderIcon = maxProviderId ? PROVIDER_ICONS[maxProviderId] : AnthropicIcon;
const statusColor = getStatusInfo(maxPercent).color;
// Get list of available providers for the dropdown
const availableProviders = useMemo(() => {
if (!allUsage?.providers) return [];
return Object.entries(allUsage.providers)
.filter(([_, usage]) => usage?.available)
.map(([id, usage]) => ({ id: id as UsageProviderId, usage: usage! }));
}, [allUsage]);
const trigger = (
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
{availableCount > 0 && <MaxProviderIcon className={cn('w-4 h-4', statusColor)} />}
<span className="text-sm font-medium">Usage</span>
{availableCount > 0 && (
<div
className={cn(
'h-1.5 w-16 bg-muted-foreground/20 rounded-full overflow-hidden transition-opacity',
isStale && 'opacity-60'
)}
>
<div
className={cn('h-full transition-all duration-500', getProgressBarColor(maxPercent))}
style={{ width: `${Math.min(maxPercent, 100)}%` }}
/>
</div>
)}
{availableCount > 1 && (
<span className="text-[10px] text-muted-foreground">+{availableCount - 1}</span>
)}
</Button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent
className="w-80 p-0 overflow-hidden bg-background/95 backdrop-blur-xl border-border shadow-2xl max-h-[80vh] overflow-y-auto"
align="end"
sideOffset={8}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10 sticky top-0 z-10">
<span className="text-sm font-semibold">Provider Usage</span>
<Button
variant="ghost"
size="icon"
className={cn('h-6 w-6', isLoading && 'animate-spin')}
onClick={() => refetch()}
disabled={isLoading}
>
<RefreshCw className="w-3.5 h-3.5" />
</Button>
</div>
{/* Content */}
<div className="divide-y divide-border/50">
{isLoading && !allUsage ? (
<div className="flex flex-col items-center justify-center py-8 space-y-2">
<Spinner size="lg" />
<p className="text-xs text-muted-foreground">Loading usage data...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
<div className="space-y-1">
<p className="text-sm font-medium">Failed to load usage</p>
<p className="text-xs text-muted-foreground">
{error instanceof Error ? error.message : 'Unknown error'}
</p>
</div>
</div>
) : availableProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3 px-4">
<AlertTriangle className="w-8 h-8 text-muted-foreground/50" />
<div className="space-y-1">
<p className="text-sm font-medium">No providers available</p>
<p className="text-xs text-muted-foreground">
Configure providers in Settings to track usage
</p>
</div>
</div>
) : (
availableProviders.map(({ id, usage }) => (
<ProviderUsagePanel
key={id}
providerId={id}
usage={usage}
isStale={isStale}
/>
))
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50 sticky bottom-0">
<span className="text-[10px] text-muted-foreground">
{availableCount} provider{availableCount !== 1 ? 's' : ''} active
</span>
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -2,7 +2,7 @@ import { useCallback, useState } from 'react';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Wand2, GitBranch, ClipboardCheck } from 'lucide-react';
import { UsagePopover } from '@/components/usage-popover';
import { ProviderUsageBar } from '@/components/provider-usage-bar';
import { useAppStore } from '@/store/app-store';
import { useSetupStore } from '@/store/setup-store';
import { useIsTablet } from '@/hooks/use-media-query';
@@ -127,8 +127,8 @@ export function BoardHeader({
<BoardControls isMounted={isMounted} onShowBoardBackground={onShowBoardBackground} />
</div>
<div className="flex gap-4 items-center">
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Provider Usage Bar - shows all available providers, only on desktop */}
{isMounted && !isTablet && <ProviderUsageBar />}
{/* Tablet/Mobile view: show hamburger menu with all controls */}
{isMounted && isTablet && (

View File

@@ -23,7 +23,13 @@ export {
} from './use-github';
// Usage
export { useClaudeUsage, useCodexUsage } from './use-usage';
export {
useClaudeUsage,
useCodexUsage,
useAllProvidersUsage,
useProviderUsage,
useProviderAvailability,
} from './use-usage';
// Running Agents
export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';

View File

@@ -1,7 +1,7 @@
/**
* Usage Query Hooks
*
* React Query hooks for fetching Claude and Codex API usage data.
* React Query hooks for fetching Claude, Codex, and all provider API usage data.
* These hooks include automatic polling for real-time usage updates.
*/
@@ -10,6 +10,7 @@ import { getElectronAPI } from '@/lib/electron';
import { queryKeys } from '@/lib/query-keys';
import { STALE_TIMES } from '@/lib/query-client';
import type { ClaudeUsage, CodexUsage } from '@/store/app-store';
import type { AllProvidersUsage, UsageProviderId } from '@automaker/types';
/** Polling interval for usage data (60 seconds) */
const USAGE_POLLING_INTERVAL = 60 * 1000;
@@ -81,3 +82,85 @@ export function useCodexUsage(enabled = true) {
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch usage data for all enabled AI providers
*
* @param enabled - Whether the query should run (default: true)
* @returns Query result with all providers usage data
*
* @example
* ```tsx
* const { data: allUsage, isLoading } = useAllProvidersUsage(isPopoverOpen);
* ```
*/
export function useAllProvidersUsage(enabled = true) {
return useQuery({
queryKey: queryKeys.usage.all(),
queryFn: async (): Promise<AllProvidersUsage> => {
const api = getElectronAPI();
const result = await api.providerUsage.getAll();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch provider usage');
}
return result.data;
},
enabled,
staleTime: STALE_TIMES.USAGE,
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
placeholderData: (previousData) => previousData,
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
});
}
/**
* Fetch usage data for a specific provider
*
* @param providerId - The provider to fetch usage for
* @param enabled - Whether the query should run (default: true)
* @returns Query result with provider usage data
*/
export function useProviderUsage(providerId: UsageProviderId, enabled = true) {
return useQuery({
queryKey: queryKeys.usage.provider(providerId),
queryFn: async () => {
const api = getElectronAPI();
const result = await api.providerUsage.getProvider(providerId);
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch provider usage');
}
return result.data;
},
enabled,
staleTime: STALE_TIMES.USAGE,
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
placeholderData: (previousData) => previousData,
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
});
}
/**
* Check availability of all providers
*
* @param enabled - Whether the query should run (default: true)
* @returns Query result with provider availability map
*/
export function useProviderAvailability(enabled = true) {
return useQuery({
queryKey: queryKeys.usage.availability(),
queryFn: async (): Promise<Record<UsageProviderId, boolean>> => {
const api = getElectronAPI();
const result = await api.providerUsage.getAvailability();
if (!result.success || !result.data) {
throw new Error(result.error || 'Failed to fetch provider availability');
}
return result.data;
},
enabled,
staleTime: STALE_TIMES.STATUS,
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
});
}

View File

@@ -840,6 +840,26 @@ export interface ElectronAPI {
error?: string;
}>;
};
providerUsage?: {
getAll: (refresh?: boolean) => Promise<{
success: boolean;
data?: import('@automaker/types').AllProvidersUsage;
error?: string;
}>;
getProvider: (
providerId: import('@automaker/types').UsageProviderId,
refresh?: boolean
) => Promise<{
success: boolean;
data?: import('@automaker/types').ProviderUsage;
error?: string;
}>;
getAvailability: () => Promise<{
success: boolean;
data?: Record<import('@automaker/types').UsageProviderId, boolean>;
error?: string;
}>;
};
settings?: {
getStatus: () => Promise<{
success: boolean;

View File

@@ -2596,6 +2596,38 @@ export class HttpApiClient implements ElectronAPI {
},
};
// Provider Usage API (unified usage tracking for all providers)
providerUsage = {
getAll: (
refresh = false
): Promise<{
success: boolean;
data?: import('@automaker/types').AllProvidersUsage;
error?: string;
}> => {
const url = `/api/provider-usage${refresh ? '?refresh=true' : ''}`;
return this.get(url);
},
getProvider: (
providerId: import('@automaker/types').UsageProviderId,
refresh = false
): Promise<{
success: boolean;
data?: import('@automaker/types').ProviderUsage;
error?: string;
}> => {
const url = `/api/provider-usage/${providerId}${refresh ? '?refresh=true' : ''}`;
return this.get(url);
},
getAvailability: (): Promise<{
success: boolean;
data?: Record<import('@automaker/types').UsageProviderId, boolean>;
error?: string;
}> => this.get('/api/provider-usage/availability'),
};
// Context API
context = {
describeImage: (

View File

@@ -99,6 +99,12 @@ export const queryKeys = {
claude: () => ['usage', 'claude'] as const,
/** Codex API usage */
codex: () => ['usage', 'codex'] as const,
/** All providers usage */
all: () => ['usage', 'all'] as const,
/** Single provider usage */
provider: (providerId: string) => ['usage', 'provider', providerId] as const,
/** Provider availability */
availability: () => ['usage', 'availability'] as const,
},
// ============================================

View File

@@ -353,3 +353,31 @@ export type { TerminalInfo } from './terminal.js';
// Test runner types
export type { TestRunnerInfo } from './test-runner.js';
// Provider usage types
export type {
UsageWindow,
ProviderPlan,
UsageCost,
UsageProviderId,
BaseProviderUsage,
ClaudeProviderUsage,
CodexProviderUsage,
CursorProviderUsage,
GeminiProviderUsage,
CopilotProviderUsage,
OpenCodeProviderUsage,
MiniMaxProviderUsage,
GLMProviderUsage,
ProviderUsage,
AllProvidersUsage,
ProviderUsageResponse,
ProviderUsageOptions,
ProviderDisplayInfo,
} from './provider-usage.js';
export {
PROVIDER_DISPLAY_INFO,
getMaxUsagePercent,
getUsageStatusColor,
formatResetTime,
} from './provider-usage.js';

View File

@@ -0,0 +1,375 @@
/**
* Provider Usage Types
*
* Unified type definitions for tracking usage across all AI providers.
* Each provider can have different usage metrics, but all share a common
* structure for display in the UI.
*/
/**
* Common usage window structure - represents a time-bounded usage period
* Used by Claude (session/weekly), Codex (rate limits), Cursor, Gemini, etc.
*/
export interface UsageWindow {
/** Display name for this window (e.g., "5-hour Session", "Weekly Limit") */
name: string;
/** Percentage of quota used (0-100) */
usedPercent: number;
/** When this window resets (ISO date string) */
resetsAt: string;
/** Human-readable reset text (e.g., "Resets in 2h 15m") */
resetText: string;
/** Window duration in minutes (if applicable) */
windowDurationMins?: number;
/** Raw limit value (if available) */
limit?: number;
/** Raw used value (if available) */
used?: number;
/** Raw remaining value (if available) */
remaining?: number;
}
/**
* Plan/tier information for a provider
*/
export interface ProviderPlan {
/** Plan type identifier (e.g., "free", "pro", "max", "team", "enterprise") */
type: string;
/** Display name for the plan */
displayName: string;
/** Whether this is a paid plan */
isPaid?: boolean;
}
/**
* Cost/billing information (for pay-per-use providers)
*/
export interface UsageCost {
/** Amount used in current billing period */
used: number;
/** Limit for current billing period (null if unlimited) */
limit: number | null;
/** Currency code (e.g., "USD") */
currency: string;
}
/**
* Provider identifiers for usage tracking
*/
export type UsageProviderId =
| 'claude'
| 'codex'
| 'cursor'
| 'gemini'
| 'copilot'
| 'opencode'
| 'minimax'
| 'glm';
/**
* Base interface for all provider usage data
*/
export interface BaseProviderUsage {
/** Provider identifier */
providerId: UsageProviderId;
/** Provider display name */
providerName: string;
/** Whether this provider is available and authenticated */
available: boolean;
/** Primary usage window (most important metric) */
primary?: UsageWindow;
/** Secondary usage window (if applicable) */
secondary?: UsageWindow;
/** Additional usage windows (for providers with more than 2) */
additional?: UsageWindow[];
/** Plan/tier information */
plan?: ProviderPlan;
/** Cost/billing information */
cost?: UsageCost;
/** Last time usage was fetched (ISO date string) */
lastUpdated: string;
/** Error message if fetching failed */
error?: string;
}
/**
* Claude-specific usage data
*/
export interface ClaudeProviderUsage extends BaseProviderUsage {
providerId: 'claude';
/** Session (5-hour) usage window */
sessionWindow?: UsageWindow;
/** Weekly (all models) usage window */
weeklyWindow?: UsageWindow;
/** Sonnet-specific weekly usage window */
sonnetWindow?: UsageWindow;
/** User's timezone */
userTimezone?: string;
}
/**
* Codex-specific usage data
*/
export interface CodexProviderUsage extends BaseProviderUsage {
providerId: 'codex';
/** Plan type (free, plus, pro, team, enterprise, edu) */
planType?: string;
}
/**
* Cursor-specific usage data
*/
export interface CursorProviderUsage extends BaseProviderUsage {
providerId: 'cursor';
/** Included plan usage (fast requests) */
planUsage?: UsageWindow;
/** On-demand/overage usage */
onDemandUsage?: UsageWindow;
/** On-demand cost in USD */
onDemandCostUsd?: number;
/** Billing cycle end date */
billingCycleEnd?: string;
}
/**
* Gemini-specific usage data
*/
export interface GeminiProviderUsage extends BaseProviderUsage {
providerId: 'gemini';
/** Quota remaining fraction (0-1) */
remainingFraction?: number;
/** Model ID for quota */
modelId?: string;
/** Tier type (standard, free, workspace, legacy) */
tierType?: string;
}
/**
* GitHub Copilot-specific usage data
*/
export interface CopilotProviderUsage extends BaseProviderUsage {
providerId: 'copilot';
/** Premium interactions quota */
premiumInteractions?: UsageWindow;
/** Chat quota */
chatQuota?: UsageWindow;
/** Copilot plan type */
copilotPlan?: string;
}
/**
* OpenCode-specific usage data
*/
export interface OpenCodeProviderUsage extends BaseProviderUsage {
providerId: 'opencode';
/** Rolling 5-hour usage window */
rollingWindow?: UsageWindow;
/** Weekly usage window */
weeklyWindow?: UsageWindow;
/** Workspace ID */
workspaceId?: string;
}
/**
* MiniMax-specific usage data
*/
export interface MiniMaxProviderUsage extends BaseProviderUsage {
providerId: 'minimax';
/** Coding plan token remains */
tokenRemains?: number;
/** Total tokens in plan */
totalTokens?: number;
/** Plan start time */
planStartTime?: string;
/** Plan end time */
planEndTime?: string;
}
/**
* GLM (z.AI)-specific usage data
*/
export interface GLMProviderUsage extends BaseProviderUsage {
providerId: 'glm';
/** Coding plan usage similar to MiniMax */
tokenRemains?: number;
totalTokens?: number;
planStartTime?: string;
planEndTime?: string;
}
/**
* Union type of all provider usage types
*/
export type ProviderUsage =
| ClaudeProviderUsage
| CodexProviderUsage
| CursorProviderUsage
| GeminiProviderUsage
| CopilotProviderUsage
| OpenCodeProviderUsage
| MiniMaxProviderUsage
| GLMProviderUsage;
/**
* Aggregated usage data from all providers
*/
export interface AllProvidersUsage {
/** Usage data by provider ID */
providers: Partial<Record<UsageProviderId, ProviderUsage>>;
/** Last time any usage was fetched */
lastUpdated: string;
/** List of providers that are enabled but had errors */
errors: Array<{ providerId: UsageProviderId; message: string }>;
}
/**
* Response type for the unified usage endpoint
*/
export interface ProviderUsageResponse {
success: boolean;
data?: AllProvidersUsage;
error?: string;
}
/**
* Request options for fetching provider usage
*/
export interface ProviderUsageOptions {
/** Which providers to fetch usage for (empty = all enabled) */
providers?: UsageProviderId[];
/** Force refresh even if cached data is fresh */
forceRefresh?: boolean;
}
/**
* Provider display information for UI
*/
export interface ProviderDisplayInfo {
id: UsageProviderId;
name: string;
icon: string;
color: string;
statusUrl?: string;
dashboardUrl?: string;
}
/**
* Provider display metadata
*/
export const PROVIDER_DISPLAY_INFO: Record<UsageProviderId, ProviderDisplayInfo> = {
claude: {
id: 'claude',
name: 'Claude',
icon: 'anthropic',
color: '#D97706',
statusUrl: 'https://status.claude.com',
dashboardUrl: 'https://console.anthropic.com',
},
codex: {
id: 'codex',
name: 'Codex',
icon: 'openai',
color: '#10A37F',
statusUrl: 'https://status.openai.com',
dashboardUrl: 'https://platform.openai.com/usage',
},
cursor: {
id: 'cursor',
name: 'Cursor',
icon: 'cursor',
color: '#6366F1',
dashboardUrl: 'https://cursor.com/settings',
},
gemini: {
id: 'gemini',
name: 'Gemini',
icon: 'google',
color: '#4285F4',
dashboardUrl: 'https://aistudio.google.com',
},
copilot: {
id: 'copilot',
name: 'GitHub Copilot',
icon: 'github',
color: '#24292E',
dashboardUrl: 'https://github.com/settings/copilot',
},
opencode: {
id: 'opencode',
name: 'OpenCode',
icon: 'opencode',
color: '#FF6B6B',
dashboardUrl: 'https://opencode.ai',
},
minimax: {
id: 'minimax',
name: 'MiniMax',
icon: 'minimax',
color: '#FF4081',
dashboardUrl: 'https://platform.minimax.io/user-center/payment/coding-plan',
},
glm: {
id: 'glm',
name: 'z.AI GLM',
icon: 'glm',
color: '#00BFA5',
dashboardUrl: 'https://z.ai/account',
},
};
/**
* Helper to calculate the maximum usage percentage across all windows
*/
export function getMaxUsagePercent(usage: ProviderUsage): number {
let max = 0;
if (usage.primary?.usedPercent !== undefined) {
max = Math.max(max, usage.primary.usedPercent);
}
if (usage.secondary?.usedPercent !== undefined) {
max = Math.max(max, usage.secondary.usedPercent);
}
if (usage.additional) {
for (const window of usage.additional) {
if (window.usedPercent !== undefined) {
max = Math.max(max, window.usedPercent);
}
}
}
return max;
}
/**
* Helper to get usage status color based on percentage
*/
export function getUsageStatusColor(percent: number): 'green' | 'yellow' | 'orange' | 'red' {
if (percent >= 90) return 'red';
if (percent >= 75) return 'orange';
if (percent >= 50) return 'yellow';
return 'green';
}
/**
* Helper to format reset time as human-readable string
*/
export function formatResetTime(resetAt: string | Date): string {
const date = typeof resetAt === 'string' ? new Date(resetAt) : resetAt;
const now = new Date();
const diff = date.getTime() - now.getTime();
if (diff < 0) return 'Expired';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) {
return `Resets in ${days}d ${hours % 24}h`;
}
if (hours > 0) {
return `Resets in ${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `Resets in ${minutes}m`;
}
return 'Resets soon';
}