mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
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:
@@ -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);
|
||||
|
||||
143
apps/server/src/routes/provider-usage/index.ts
Normal file
143
apps/server/src/routes/provider-usage/index.ts
Normal 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;
|
||||
}
|
||||
288
apps/server/src/services/copilot-usage-service.ts
Normal file
288
apps/server/src/services/copilot-usage-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
331
apps/server/src/services/cursor-usage-service.ts
Normal file
331
apps/server/src/services/cursor-usage-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
362
apps/server/src/services/gemini-usage-service.ts
Normal file
362
apps/server/src/services/gemini-usage-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
140
apps/server/src/services/glm-usage-service.ts
Normal file
140
apps/server/src/services/glm-usage-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
260
apps/server/src/services/minimax-usage-service.ts
Normal file
260
apps/server/src/services/minimax-usage-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
144
apps/server/src/services/opencode-usage-service.ts
Normal file
144
apps/server/src/services/opencode-usage-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
447
apps/server/src/services/provider-usage-tracker.ts
Normal file
447
apps/server/src/services/provider-usage-tracker.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
389
apps/ui/src/components/provider-usage-bar.tsx
Normal file
389
apps/ui/src/components/provider-usage-bar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -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';
|
||||
|
||||
375
libs/types/src/provider-usage.ts
Normal file
375
libs/types/src/provider-usage.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user