mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 10:03:08 +00:00
Feat: Show Gemini Usage in usage dropdown and mobile sidebar
This commit is contained in:
@@ -68,6 +68,7 @@ import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||
import { CodexModelCacheService } from './services/codex-model-cache-service.js';
|
||||
import { createZaiRoutes } from './routes/zai/index.js';
|
||||
import { ZaiUsageService } from './services/zai-usage-service.js';
|
||||
import { createGeminiRoutes } from './routes/gemini/index.js';
|
||||
import { createGitHubRoutes } from './routes/github/index.js';
|
||||
import { createContextRoutes } from './routes/context/index.js';
|
||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||
@@ -438,6 +439,7 @@ app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
|
||||
app.use('/api/gemini', createGeminiRoutes());
|
||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||
app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
|
||||
60
apps/server/src/routes/gemini/index.ts
Normal file
60
apps/server/src/routes/gemini/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { GeminiProvider } from '../../providers/gemini-provider.js';
|
||||
import { getGeminiUsageService } from '../../services/gemini-usage-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
|
||||
const logger = createLogger('Gemini');
|
||||
|
||||
export function createGeminiRoutes(): Router {
|
||||
const router = Router();
|
||||
|
||||
// Get current usage/quota data from Google Cloud API
|
||||
router.get('/usage', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const usageService = getGeminiUsageService();
|
||||
const usageData = await usageService.fetchUsageData();
|
||||
|
||||
res.json(usageData);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('Error fetching Gemini usage:', error);
|
||||
|
||||
// Return error in a format the UI expects
|
||||
res.status(200).json({
|
||||
authenticated: false,
|
||||
authMethod: 'none',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: `Failed to fetch Gemini usage: ${message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check if Gemini is available
|
||||
router.get('/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const provider = new GeminiProvider();
|
||||
const status = await provider.detectInstallation();
|
||||
|
||||
const authMethod =
|
||||
(status as any).authMethod ||
|
||||
(status.authenticated ? (status.hasApiKey ? 'api_key' : 'cli_login') : 'none');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
installed: status.installed,
|
||||
version: status.version || null,
|
||||
path: status.path || null,
|
||||
authenticated: status.authenticated || false,
|
||||
authMethod,
|
||||
hasCredentialsFile: (status as any).hasCredentialsFile || false,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
761
apps/server/src/services/gemini-usage-service.ts
Normal file
761
apps/server/src/services/gemini-usage-service.ts
Normal file
@@ -0,0 +1,761 @@
|
||||
/**
|
||||
* Gemini Usage Service
|
||||
*
|
||||
* Service for tracking Gemini CLI usage and quota.
|
||||
* Uses the internal Google Cloud quota API (same as CodexBar).
|
||||
* See: https://github.com/steipete/CodexBar/blob/main/docs/gemini.md
|
||||
*
|
||||
* OAuth credentials are extracted from the Gemini CLI installation,
|
||||
* not hardcoded, to ensure compatibility with CLI updates.
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
const logger = createLogger('GeminiUsage');
|
||||
|
||||
// Quota API endpoint (internal Google Cloud API)
|
||||
const QUOTA_API_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
|
||||
// Code Assist endpoint for getting project ID and tier info
|
||||
const CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
|
||||
// Google OAuth endpoints for token refresh
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
|
||||
export interface GeminiQuotaBucket {
|
||||
/** Model ID this quota applies to */
|
||||
modelId: string;
|
||||
/** Remaining fraction (0-1) */
|
||||
remainingFraction: number;
|
||||
/** ISO-8601 reset time */
|
||||
resetTime: string;
|
||||
}
|
||||
|
||||
/** Simplified quota info for a model tier (Flash or Pro) */
|
||||
export interface GeminiTierQuota {
|
||||
/** Used percentage (0-100) */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100) */
|
||||
remainingPercent: number;
|
||||
/** Reset time as human-readable string */
|
||||
resetText?: string;
|
||||
/** ISO-8601 reset time */
|
||||
resetTime?: string;
|
||||
}
|
||||
|
||||
export interface GeminiUsageData {
|
||||
/** Whether authenticated via CLI */
|
||||
authenticated: boolean;
|
||||
/** Authentication method */
|
||||
authMethod: 'cli_login' | 'api_key' | 'none';
|
||||
/** Usage percentage (100 - remainingFraction * 100) - overall most constrained */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage - overall most constrained */
|
||||
remainingPercent: number;
|
||||
/** Reset time as human-readable string */
|
||||
resetText?: string;
|
||||
/** ISO-8601 reset time */
|
||||
resetTime?: string;
|
||||
/** Model ID with lowest remaining quota */
|
||||
constrainedModel?: string;
|
||||
/** Flash tier quota (aggregated from all flash models) */
|
||||
flashQuota?: GeminiTierQuota;
|
||||
/** Pro tier quota (aggregated from all pro models) */
|
||||
proQuota?: GeminiTierQuota;
|
||||
/** Raw quota buckets for detailed view */
|
||||
quotaBuckets?: GeminiQuotaBucket[];
|
||||
/** When this data was last fetched */
|
||||
lastUpdated: string;
|
||||
/** Optional error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OAuthCredentials {
|
||||
access_token?: string;
|
||||
id_token?: string;
|
||||
refresh_token?: string;
|
||||
token_type?: string;
|
||||
expiry_date?: number;
|
||||
client_id?: string;
|
||||
client_secret?: string;
|
||||
}
|
||||
|
||||
interface OAuthClientCredentials {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
interface QuotaResponse {
|
||||
// The actual API returns 'buckets', not 'quotaBuckets'
|
||||
buckets?: Array<{
|
||||
modelId?: string;
|
||||
remainingFraction?: number;
|
||||
resetTime?: string;
|
||||
tokenType?: string;
|
||||
}>;
|
||||
// Legacy field name (in case API changes)
|
||||
quotaBuckets?: Array<{
|
||||
modelId?: string;
|
||||
remainingFraction?: number;
|
||||
resetTime?: string;
|
||||
tokenType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini Usage Service
|
||||
*
|
||||
* Provides real usage/quota data for Gemini CLI users.
|
||||
* Extracts OAuth credentials from the Gemini CLI installation.
|
||||
*/
|
||||
export class GeminiUsageService {
|
||||
private cachedCredentials: OAuthCredentials | null = null;
|
||||
private cachedClientCredentials: OAuthClientCredentials | null = null;
|
||||
private credentialsPath: string;
|
||||
|
||||
constructor() {
|
||||
// Default credentials path for Gemini CLI
|
||||
this.credentialsPath = path.join(os.homedir(), '.gemini', 'oauth_creds.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Gemini CLI is authenticated
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
const creds = await this.loadCredentials();
|
||||
return Boolean(creds?.access_token || creds?.refresh_token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch quota/usage data from Google Cloud API
|
||||
*/
|
||||
async fetchUsageData(): Promise<GeminiUsageData> {
|
||||
logger.info('[fetchUsageData] Starting...');
|
||||
|
||||
const creds = await this.loadCredentials();
|
||||
|
||||
if (!creds || (!creds.access_token && !creds.refresh_token)) {
|
||||
logger.info('[fetchUsageData] No credentials found');
|
||||
return {
|
||||
authenticated: false,
|
||||
authMethod: 'none',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Not authenticated. Run "gemini auth login" to authenticate.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get a valid access token (refresh if needed)
|
||||
const accessToken = await this.getValidAccessToken(creds);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
authMethod: 'none',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: 'Failed to obtain access token. Try running "gemini auth login" again.',
|
||||
};
|
||||
}
|
||||
|
||||
// First, get the project ID from loadCodeAssist endpoint
|
||||
// This is required to get accurate quota data
|
||||
let projectId: string | undefined;
|
||||
try {
|
||||
const codeAssistResponse = await fetch(CODE_ASSIST_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (codeAssistResponse.ok) {
|
||||
const codeAssistData = (await codeAssistResponse.json()) as {
|
||||
cloudaicompanionProject?: string;
|
||||
currentTier?: { id?: string; name?: string };
|
||||
};
|
||||
projectId = codeAssistData.cloudaicompanionProject;
|
||||
logger.debug('[fetchUsageData] Got project ID:', projectId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.debug('[fetchUsageData] Failed to get project ID:', e);
|
||||
}
|
||||
|
||||
// Fetch quota from Google Cloud API
|
||||
// Pass project ID to get accurate quota (without it, returns default 100%)
|
||||
const response = await fetch(QUOTA_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectId ? { project: projectId } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
logger.error('[fetchUsageData] Quota API error:', response.status, errorText);
|
||||
|
||||
// Still authenticated, but quota API failed
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'cli_login',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: `Quota API unavailable (${response.status})`,
|
||||
};
|
||||
}
|
||||
|
||||
const data = (await response.json()) as QuotaResponse;
|
||||
|
||||
// API returns 'buckets', with fallback to 'quotaBuckets' for compatibility
|
||||
const apiBuckets = data.buckets || data.quotaBuckets;
|
||||
|
||||
logger.debug('[fetchUsageData] Raw buckets:', JSON.stringify(apiBuckets));
|
||||
|
||||
if (!apiBuckets || apiBuckets.length === 0) {
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'cli_login',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Group buckets into Flash and Pro tiers
|
||||
// Flash: any model with "flash" in the name
|
||||
// Pro: any model with "pro" in the name
|
||||
let flashLowestRemaining = 1.0;
|
||||
let flashResetTime: string | undefined;
|
||||
let hasFlashModels = false;
|
||||
let proLowestRemaining = 1.0;
|
||||
let proResetTime: string | undefined;
|
||||
let hasProModels = false;
|
||||
let overallLowestRemaining = 1.0;
|
||||
let constrainedModel: string | undefined;
|
||||
let overallResetTime: string | undefined;
|
||||
|
||||
const quotaBuckets: GeminiQuotaBucket[] = apiBuckets.map((bucket) => {
|
||||
const remaining = bucket.remainingFraction ?? 1.0;
|
||||
const modelId = bucket.modelId?.toLowerCase() || '';
|
||||
|
||||
// Track overall lowest
|
||||
if (remaining < overallLowestRemaining) {
|
||||
overallLowestRemaining = remaining;
|
||||
constrainedModel = bucket.modelId;
|
||||
overallResetTime = bucket.resetTime;
|
||||
}
|
||||
|
||||
// Group into Flash or Pro tier
|
||||
if (modelId.includes('flash')) {
|
||||
hasFlashModels = true;
|
||||
if (remaining < flashLowestRemaining) {
|
||||
flashLowestRemaining = remaining;
|
||||
flashResetTime = bucket.resetTime;
|
||||
}
|
||||
// Also track reset time even if at 100%
|
||||
if (!flashResetTime && bucket.resetTime) {
|
||||
flashResetTime = bucket.resetTime;
|
||||
}
|
||||
} else if (modelId.includes('pro')) {
|
||||
hasProModels = true;
|
||||
if (remaining < proLowestRemaining) {
|
||||
proLowestRemaining = remaining;
|
||||
proResetTime = bucket.resetTime;
|
||||
}
|
||||
// Also track reset time even if at 100%
|
||||
if (!proResetTime && bucket.resetTime) {
|
||||
proResetTime = bucket.resetTime;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modelId: bucket.modelId || 'unknown',
|
||||
remainingFraction: remaining,
|
||||
resetTime: bucket.resetTime || '',
|
||||
};
|
||||
});
|
||||
|
||||
const usedPercent = Math.round((1 - overallLowestRemaining) * 100);
|
||||
const remainingPercent = Math.round(overallLowestRemaining * 100);
|
||||
|
||||
// Build tier quotas (only include if we found models for that tier)
|
||||
const flashQuota: GeminiTierQuota | undefined = hasFlashModels
|
||||
? {
|
||||
usedPercent: Math.round((1 - flashLowestRemaining) * 100),
|
||||
remainingPercent: Math.round(flashLowestRemaining * 100),
|
||||
resetText: flashResetTime ? this.formatResetTime(flashResetTime) : undefined,
|
||||
resetTime: flashResetTime,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const proQuota: GeminiTierQuota | undefined = hasProModels
|
||||
? {
|
||||
usedPercent: Math.round((1 - proLowestRemaining) * 100),
|
||||
remainingPercent: Math.round(proLowestRemaining * 100),
|
||||
resetText: proResetTime ? this.formatResetTime(proResetTime) : undefined,
|
||||
resetTime: proResetTime,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'cli_login',
|
||||
usedPercent,
|
||||
remainingPercent,
|
||||
resetText: overallResetTime ? this.formatResetTime(overallResetTime) : undefined,
|
||||
resetTime: overallResetTime,
|
||||
constrainedModel,
|
||||
flashQuota,
|
||||
proQuota,
|
||||
quotaBuckets,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
logger.error('[fetchUsageData] Error:', errorMsg);
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'cli_login',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
error: `Failed to fetch quota: ${errorMsg}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OAuth credentials from file
|
||||
*/
|
||||
private async loadCredentials(): Promise<OAuthCredentials | null> {
|
||||
if (this.cachedCredentials) {
|
||||
return this.cachedCredentials;
|
||||
}
|
||||
|
||||
// Check multiple possible paths
|
||||
const possiblePaths = [
|
||||
this.credentialsPath,
|
||||
path.join(os.homedir(), '.gemini', 'oauth_creds.json'),
|
||||
path.join(os.homedir(), '.config', 'gemini', 'oauth_creds.json'),
|
||||
];
|
||||
|
||||
for (const credPath of possiblePaths) {
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
const content = fs.readFileSync(credPath, 'utf8');
|
||||
const creds = JSON.parse(content);
|
||||
|
||||
// Handle different credential formats
|
||||
if (creds.access_token || creds.refresh_token) {
|
||||
this.cachedCredentials = creds;
|
||||
logger.info('[loadCredentials] Loaded from:', credPath);
|
||||
return creds;
|
||||
}
|
||||
|
||||
// Some formats nest credentials under 'web' or 'installed'
|
||||
if (creds.web?.client_id || creds.installed?.client_id) {
|
||||
const clientCreds = creds.web || creds.installed;
|
||||
this.cachedCredentials = {
|
||||
client_id: clientCreds.client_id,
|
||||
client_secret: clientCreds.client_secret,
|
||||
};
|
||||
return this.cachedCredentials;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('[loadCredentials] Failed to load from', credPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Gemini CLI binary path
|
||||
*/
|
||||
private findGeminiBinaryPath(): string | null {
|
||||
try {
|
||||
// Try 'which' on Unix-like systems
|
||||
const whichResult = execSync('which gemini 2>/dev/null', { encoding: 'utf8' }).trim();
|
||||
if (whichResult && fs.existsSync(whichResult)) {
|
||||
return whichResult;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors from 'which'
|
||||
}
|
||||
|
||||
// Check common installation paths
|
||||
const possiblePaths = [
|
||||
// npm global installs
|
||||
path.join(os.homedir(), '.npm-global', 'bin', 'gemini'),
|
||||
'/usr/local/bin/gemini',
|
||||
'/usr/bin/gemini',
|
||||
// Homebrew
|
||||
'/opt/homebrew/bin/gemini',
|
||||
'/usr/local/opt/gemini/bin/gemini',
|
||||
// nvm/fnm node installs
|
||||
path.join(os.homedir(), '.nvm', 'versions', 'node'),
|
||||
path.join(os.homedir(), '.fnm', 'node-versions'),
|
||||
// Windows
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini.cmd'),
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'npm', 'gemini'),
|
||||
];
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
if (fs.existsSync(p)) {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract OAuth client credentials from Gemini CLI installation
|
||||
* This mimics CodexBar's approach of finding oauth2.js in the CLI
|
||||
*/
|
||||
private extractOAuthClientCredentials(): OAuthClientCredentials | null {
|
||||
if (this.cachedClientCredentials) {
|
||||
return this.cachedClientCredentials;
|
||||
}
|
||||
|
||||
const geminiBinary = this.findGeminiBinaryPath();
|
||||
if (!geminiBinary) {
|
||||
logger.debug('[extractOAuthClientCredentials] Gemini binary not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve symlinks to find actual location
|
||||
let resolvedPath = geminiBinary;
|
||||
try {
|
||||
resolvedPath = fs.realpathSync(geminiBinary);
|
||||
} catch {
|
||||
// Use original path if realpath fails
|
||||
}
|
||||
|
||||
const baseDir = path.dirname(resolvedPath);
|
||||
logger.debug('[extractOAuthClientCredentials] Base dir:', baseDir);
|
||||
|
||||
// Possible locations for oauth2.js relative to the binary
|
||||
// Based on CodexBar's search patterns
|
||||
const possibleOAuth2Paths = [
|
||||
// npm global install structure
|
||||
path.join(
|
||||
baseDir,
|
||||
'..',
|
||||
'lib',
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js'
|
||||
),
|
||||
path.join(
|
||||
baseDir,
|
||||
'..',
|
||||
'lib',
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli-core',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js'
|
||||
),
|
||||
// Homebrew/libexec structure
|
||||
path.join(
|
||||
baseDir,
|
||||
'..',
|
||||
'libexec',
|
||||
'lib',
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js'
|
||||
),
|
||||
path.join(
|
||||
baseDir,
|
||||
'..',
|
||||
'libexec',
|
||||
'lib',
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli-core',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js'
|
||||
),
|
||||
// Direct sibling
|
||||
path.join(baseDir, '..', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js'),
|
||||
path.join(baseDir, '..', 'gemini-cli', 'dist', 'src', 'code_assist', 'oauth2.js'),
|
||||
// Alternative node_modules structures
|
||||
path.join(
|
||||
baseDir,
|
||||
'..',
|
||||
'..',
|
||||
'lib',
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js'
|
||||
),
|
||||
path.join(
|
||||
baseDir,
|
||||
'..',
|
||||
'..',
|
||||
'lib',
|
||||
'node_modules',
|
||||
'@google',
|
||||
'gemini-cli-core',
|
||||
'dist',
|
||||
'src',
|
||||
'code_assist',
|
||||
'oauth2.js'
|
||||
),
|
||||
];
|
||||
|
||||
for (const oauth2Path of possibleOAuth2Paths) {
|
||||
try {
|
||||
const normalizedPath = path.normalize(oauth2Path);
|
||||
if (fs.existsSync(normalizedPath)) {
|
||||
logger.debug('[extractOAuthClientCredentials] Found oauth2.js at:', normalizedPath);
|
||||
const content = fs.readFileSync(normalizedPath, 'utf8');
|
||||
const creds = this.parseOAuthCredentialsFromSource(content);
|
||||
if (creds) {
|
||||
this.cachedClientCredentials = creds;
|
||||
logger.info('[extractOAuthClientCredentials] Extracted credentials from CLI');
|
||||
return creds;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug('[extractOAuthClientCredentials] Failed to read', oauth2Path, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Try finding oauth2.js by searching in node_modules
|
||||
try {
|
||||
const searchResult = execSync(
|
||||
`find ${baseDir}/.. -name "oauth2.js" -path "*gemini*" -path "*code_assist*" 2>/dev/null | head -1`,
|
||||
{ encoding: 'utf8', timeout: 5000 }
|
||||
).trim();
|
||||
|
||||
if (searchResult && fs.existsSync(searchResult)) {
|
||||
logger.debug('[extractOAuthClientCredentials] Found via search:', searchResult);
|
||||
const content = fs.readFileSync(searchResult, 'utf8');
|
||||
const creds = this.parseOAuthCredentialsFromSource(content);
|
||||
if (creds) {
|
||||
this.cachedClientCredentials = creds;
|
||||
logger.info(
|
||||
'[extractOAuthClientCredentials] Extracted credentials from CLI (via search)'
|
||||
);
|
||||
return creds;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore search errors
|
||||
}
|
||||
|
||||
logger.warn('[extractOAuthClientCredentials] Could not extract credentials from CLI');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse OAuth client credentials from oauth2.js source code
|
||||
*/
|
||||
private parseOAuthCredentialsFromSource(content: string): OAuthClientCredentials | null {
|
||||
// Patterns based on CodexBar's regex extraction
|
||||
// Look for: OAUTH_CLIENT_ID = "..." or const clientId = "..."
|
||||
const clientIdPatterns = [
|
||||
/OAUTH_CLIENT_ID\s*=\s*["']([^"']+)["']/,
|
||||
/clientId\s*[:=]\s*["']([^"']+)["']/,
|
||||
/client_id\s*[:=]\s*["']([^"']+)["']/,
|
||||
/"clientId"\s*:\s*["']([^"']+)["']/,
|
||||
];
|
||||
|
||||
const clientSecretPatterns = [
|
||||
/OAUTH_CLIENT_SECRET\s*=\s*["']([^"']+)["']/,
|
||||
/clientSecret\s*[:=]\s*["']([^"']+)["']/,
|
||||
/client_secret\s*[:=]\s*["']([^"']+)["']/,
|
||||
/"clientSecret"\s*:\s*["']([^"']+)["']/,
|
||||
];
|
||||
|
||||
let clientId: string | null = null;
|
||||
let clientSecret: string | null = null;
|
||||
|
||||
for (const pattern of clientIdPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
clientId = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of clientSecretPatterns) {
|
||||
const match = content.match(pattern);
|
||||
if (match && match[1]) {
|
||||
clientSecret = match[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (clientId && clientSecret) {
|
||||
logger.debug('[parseOAuthCredentialsFromSource] Found client credentials');
|
||||
return { clientId, clientSecret };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if necessary
|
||||
*/
|
||||
private async getValidAccessToken(creds: OAuthCredentials): Promise<string | null> {
|
||||
// Check if current token is still valid (with 5 min buffer)
|
||||
if (creds.access_token && creds.expiry_date) {
|
||||
const now = Date.now();
|
||||
if (creds.expiry_date > now + 5 * 60 * 1000) {
|
||||
logger.debug('[getValidAccessToken] Using existing token (not expired)');
|
||||
return creds.access_token;
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a refresh token, try to refresh
|
||||
if (creds.refresh_token) {
|
||||
// Try to extract credentials from CLI first
|
||||
const extractedCreds = this.extractOAuthClientCredentials();
|
||||
|
||||
// Use extracted credentials, then fall back to credentials in file
|
||||
const clientId = extractedCreds?.clientId || creds.client_id;
|
||||
const clientSecret = extractedCreds?.clientSecret || creds.client_secret;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
logger.error('[getValidAccessToken] No client credentials available for token refresh');
|
||||
// Return existing token even if expired - it might still work
|
||||
return creds.access_token || null;
|
||||
}
|
||||
|
||||
try {
|
||||
logger.debug('[getValidAccessToken] Refreshing token...');
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
refresh_token: creds.refresh_token,
|
||||
grant_type: 'refresh_token',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { access_token?: string; expires_in?: number };
|
||||
const newAccessToken = data.access_token;
|
||||
const expiresIn = data.expires_in || 3600;
|
||||
|
||||
if (newAccessToken) {
|
||||
logger.info('[getValidAccessToken] Token refreshed successfully');
|
||||
|
||||
// Update cached credentials
|
||||
this.cachedCredentials = {
|
||||
...creds,
|
||||
access_token: newAccessToken,
|
||||
expiry_date: Date.now() + expiresIn * 1000,
|
||||
};
|
||||
|
||||
// Save back to file
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
this.credentialsPath,
|
||||
JSON.stringify(this.cachedCredentials, null, 2)
|
||||
);
|
||||
} catch (e) {
|
||||
logger.debug('[getValidAccessToken] Could not save refreshed token:', e);
|
||||
}
|
||||
|
||||
return newAccessToken;
|
||||
}
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => '');
|
||||
logger.error('[getValidAccessToken] Token refresh failed:', response.status, errorText);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[getValidAccessToken] Token refresh error:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Return current access token even if it might be expired
|
||||
return creds.access_token || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format reset time as human-readable string
|
||||
*/
|
||||
private formatResetTime(isoTime: string): string {
|
||||
try {
|
||||
const resetDate = new Date(isoTime);
|
||||
const now = new Date();
|
||||
const diff = resetDate.getTime() - now.getTime();
|
||||
|
||||
if (diff < 0) {
|
||||
return 'Resetting soon';
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
const remainingMins = minutes % 60;
|
||||
return remainingMins > 0 ? `Resets in ${hours}h ${remainingMins}m` : `Resets in ${hours}h`;
|
||||
}
|
||||
|
||||
return `Resets in ${minutes}m`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cached credentials (useful after logout)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cachedCredentials = null;
|
||||
this.cachedClientCredentials = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let usageServiceInstance: GeminiUsageService | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton instance of GeminiUsageService
|
||||
*/
|
||||
export function getGeminiUsageService(): GeminiUsageService {
|
||||
if (!usageServiceInstance) {
|
||||
usageServiceInstance = new GeminiUsageService();
|
||||
}
|
||||
return usageServiceInstance;
|
||||
}
|
||||
@@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSetupStore } from '@/store/setup-store';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } from '@/hooks/queries';
|
||||
|
||||
// Error codes for distinguishing failure modes
|
||||
const ERROR_CODES = {
|
||||
@@ -81,14 +81,16 @@ export function UsagePopover() {
|
||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus);
|
||||
const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude');
|
||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai' | 'gemini'>('claude');
|
||||
|
||||
// Check authentication status
|
||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||
const isZaiAuthenticated = zaiAuthStatus?.authenticated;
|
||||
const isGeminiAuthenticated = geminiAuthStatus?.authenticated;
|
||||
|
||||
// Use React Query hooks for usage data
|
||||
// Only enable polling when popover is open AND the tab is active
|
||||
@@ -116,6 +118,14 @@ export function UsagePopover() {
|
||||
refetch: refetchZai,
|
||||
} = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated);
|
||||
|
||||
const {
|
||||
data: geminiUsage,
|
||||
isLoading: geminiLoading,
|
||||
error: geminiQueryError,
|
||||
dataUpdatedAt: geminiUsageLastUpdated,
|
||||
refetch: refetchGemini,
|
||||
} = useGeminiUsage(open && activeTab === 'gemini' && isGeminiAuthenticated);
|
||||
|
||||
// Parse errors into structured format
|
||||
const claudeError = useMemo((): UsageError | null => {
|
||||
if (!claudeQueryError) return null;
|
||||
@@ -157,6 +167,19 @@ export function UsagePopover() {
|
||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||
}, [zaiQueryError]);
|
||||
|
||||
const geminiError = useMemo((): UsageError | null => {
|
||||
if (!geminiQueryError) return null;
|
||||
const message =
|
||||
geminiQueryError instanceof Error ? geminiQueryError.message : String(geminiQueryError);
|
||||
if (message.includes('not configured') || message.includes('not authenticated')) {
|
||||
return { code: ERROR_CODES.NOT_AVAILABLE, message };
|
||||
}
|
||||
if (message.includes('API bridge')) {
|
||||
return { code: ERROR_CODES.API_BRIDGE_UNAVAILABLE, message };
|
||||
}
|
||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||
}, [geminiQueryError]);
|
||||
|
||||
// Determine which tab to show by default
|
||||
useEffect(() => {
|
||||
if (isClaudeAuthenticated) {
|
||||
@@ -165,8 +188,10 @@ export function UsagePopover() {
|
||||
setActiveTab('codex');
|
||||
} else if (isZaiAuthenticated) {
|
||||
setActiveTab('zai');
|
||||
} else if (isGeminiAuthenticated) {
|
||||
setActiveTab('gemini');
|
||||
}
|
||||
}, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]);
|
||||
}, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated, isGeminiAuthenticated]);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale = useMemo(() => {
|
||||
@@ -181,10 +206,15 @@ export function UsagePopover() {
|
||||
return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [zaiUsageLastUpdated]);
|
||||
|
||||
const isGeminiStale = useMemo(() => {
|
||||
return !geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000;
|
||||
}, [geminiUsageLastUpdated]);
|
||||
|
||||
// Refetch functions for manual refresh
|
||||
const fetchClaudeUsage = () => refetchClaude();
|
||||
const fetchCodexUsage = () => refetchCodex();
|
||||
const fetchZaiUsage = () => refetchZai();
|
||||
const fetchGeminiUsage = () => refetchGemini();
|
||||
|
||||
// Derived status color/icon helper
|
||||
const getStatusInfo = (percentage: number) => {
|
||||
@@ -275,6 +305,23 @@ export function UsagePopover() {
|
||||
// Calculate max percentage for header button
|
||||
const claudeSessionPercentage = claudeUsage?.sessionPercentage || 0;
|
||||
|
||||
const codexMaxPercentage = codexUsage?.rateLimits
|
||||
? Math.max(
|
||||
codexUsage.rateLimits.primary?.usedPercent || 0,
|
||||
codexUsage.rateLimits.secondary?.usedPercent || 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
const zaiMaxPercentage = zaiUsage?.quotaLimits
|
||||
? Math.max(
|
||||
zaiUsage.quotaLimits.tokens?.usedPercent || 0,
|
||||
zaiUsage.quotaLimits.mcp?.usedPercent || 0
|
||||
)
|
||||
: 0;
|
||||
|
||||
// Gemini quota from Google Cloud API (if available)
|
||||
const geminiMaxPercentage = geminiUsage?.usedPercent ?? (geminiUsage?.authenticated ? 0 : 100);
|
||||
|
||||
const getProgressBarColor = (percentage: number) => {
|
||||
if (percentage >= 80) return 'bg-red-500';
|
||||
if (percentage >= 50) return 'bg-yellow-500';
|
||||
@@ -299,33 +346,43 @@ export function UsagePopover() {
|
||||
const indicatorInfo =
|
||||
activeTab === 'claude'
|
||||
? {
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeSessionPercentage,
|
||||
isStale: isClaudeStale,
|
||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||
}
|
||||
: activeTab === 'codex' ? {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexWindowUsage ?? 0,
|
||||
isStale: isCodexStale,
|
||||
title: `Usage (${codexWindowLabel})`,
|
||||
} : activeTab === 'zai' ? {
|
||||
icon: ZaiIcon,
|
||||
percentage: zaiMaxPercentage,
|
||||
isStale: isZaiStale,
|
||||
title: `Usage (z.ai)`,
|
||||
} : null;
|
||||
icon: AnthropicIcon,
|
||||
percentage: claudeSessionPercentage,
|
||||
isStale: isClaudeStale,
|
||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||
}
|
||||
: activeTab === 'codex'
|
||||
? {
|
||||
icon: OpenAIIcon,
|
||||
percentage: codexWindowUsage ?? 0,
|
||||
isStale: isCodexStale,
|
||||
}
|
||||
: activeTab === 'zai'
|
||||
? {
|
||||
icon: ZaiIcon,
|
||||
percentage: zaiMaxPercentage,
|
||||
isStale: isZaiStale,
|
||||
title: `Usage (z.ai)`,
|
||||
}
|
||||
: activeTab === 'gemini'
|
||||
? {
|
||||
icon: GeminiIcon,
|
||||
percentage: geminiMaxPercentage,
|
||||
isStale: isGeminiStale,
|
||||
title: `Usage (Gemini)`,
|
||||
}
|
||||
: null;
|
||||
|
||||
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
||||
const ProviderIcon = indicatorInfo.icon;
|
||||
|
||||
const trigger = (
|
||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||
{(claudeUsage || codexUsage || zaiUsage) && (
|
||||
{(claudeUsage || codexUsage || zaiUsage || geminiUsage) && (
|
||||
<ProviderIcon className={cn('w-4 h-4', statusColor)} />
|
||||
)}
|
||||
<span className="text-sm font-medium">Usage</span>
|
||||
{(claudeUsage || codexUsage || zaiUsage) && (
|
||||
{(claudeUsage || codexUsage || zaiUsage || geminiUsage) && (
|
||||
<div
|
||||
title={indicatorInfo.title}
|
||||
className={cn(
|
||||
@@ -349,7 +406,8 @@ export function UsagePopover() {
|
||||
const showClaudeTab = isClaudeAuthenticated;
|
||||
const showCodexTab = isCodexAuthenticated;
|
||||
const showZaiTab = isZaiAuthenticated;
|
||||
const tabCount = [showClaudeTab, showCodexTab, showZaiTab].filter(Boolean).length;
|
||||
const showGeminiTab = isGeminiAuthenticated;
|
||||
const tabCount = [showClaudeTab, showCodexTab, showZaiTab, showGeminiTab].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -361,7 +419,7 @@ export function UsagePopover() {
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as 'claude' | 'codex' | 'zai')}
|
||||
onValueChange={(v) => setActiveTab(v as 'claude' | 'codex' | 'zai' | 'gemini')}
|
||||
>
|
||||
{/* Tabs Header */}
|
||||
{tabCount > 1 && (
|
||||
@@ -369,7 +427,8 @@ export function UsagePopover() {
|
||||
className={cn(
|
||||
'grid w-full rounded-none border-b border-border/50',
|
||||
tabCount === 2 && 'grid-cols-2',
|
||||
tabCount === 3 && 'grid-cols-3'
|
||||
tabCount === 3 && 'grid-cols-3',
|
||||
tabCount === 4 && 'grid-cols-4'
|
||||
)}
|
||||
>
|
||||
{showClaudeTab && (
|
||||
@@ -390,6 +449,12 @@ export function UsagePopover() {
|
||||
z.ai
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{showGeminiTab && (
|
||||
<TabsTrigger value="gemini" className="gap-2">
|
||||
<GeminiIcon className="w-3.5 h-3.5" />
|
||||
Gemini
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
@@ -454,13 +519,6 @@ export function UsagePopover() {
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<UsageCard
|
||||
title="Sonnet"
|
||||
subtitle="Weekly"
|
||||
percentage={claudeUsage.sonnetWeeklyPercentage}
|
||||
resetText={claudeUsage.sonnetResetText}
|
||||
stale={isClaudeStale}
|
||||
/>
|
||||
<UsageCard
|
||||
title="Weekly"
|
||||
subtitle="All models"
|
||||
@@ -744,6 +802,143 @@ export function UsagePopover() {
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Gemini Tab Content */}
|
||||
<TabsContent value="gemini" className="m-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border/50 bg-secondary/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<GeminiIcon className="w-4 h-4" />
|
||||
<span className="text-sm font-semibold">Gemini Usage</span>
|
||||
</div>
|
||||
{geminiError && geminiError.code !== ERROR_CODES.NOT_AVAILABLE && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-6 w-6', geminiLoading && 'opacity-80')}
|
||||
onClick={() => !geminiLoading && fetchGeminiUsage()}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{geminiError ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center space-y-3">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<div className="space-y-1 flex flex-col items-center">
|
||||
<p className="text-sm font-medium">
|
||||
{geminiError.code === ERROR_CODES.NOT_AVAILABLE
|
||||
? 'Gemini not configured'
|
||||
: geminiError.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{geminiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||
'Ensure the Electron bridge is running or restart the app'
|
||||
) : geminiError.code === ERROR_CODES.NOT_AVAILABLE ? (
|
||||
<>
|
||||
Run{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">gemini auth login</code>{' '}
|
||||
to authenticate with your Google account
|
||||
</>
|
||||
) : (
|
||||
<>Check your Gemini CLI configuration</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : !geminiUsage ? (
|
||||
<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>
|
||||
) : geminiUsage.authenticated ? (
|
||||
<>
|
||||
{/* Show Flash and Pro quota tiers */}
|
||||
{geminiUsage.flashQuota || geminiUsage.proQuota ? (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{geminiUsage.flashQuota && (
|
||||
<UsageCard
|
||||
title="Flash"
|
||||
subtitle="Flash models"
|
||||
percentage={geminiUsage.flashQuota.usedPercent}
|
||||
resetText={geminiUsage.flashQuota.resetText}
|
||||
stale={isGeminiStale}
|
||||
/>
|
||||
)}
|
||||
{geminiUsage.proQuota && (
|
||||
<UsageCard
|
||||
title="Pro"
|
||||
subtitle="Pro models"
|
||||
percentage={geminiUsage.proQuota.usedPercent}
|
||||
resetText={geminiUsage.proQuota.resetText}
|
||||
stale={isGeminiStale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* No quota data available - show connected status */}
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">Connected</p>
|
||||
<p className="text-xs text-emerald-400/70 mt-0.5">
|
||||
Authenticated via{' '}
|
||||
<span className="font-mono">
|
||||
{geminiUsage.authMethod === 'cli_login'
|
||||
? 'CLI Login'
|
||||
: geminiUsage.authMethod === 'api_key_env'
|
||||
? 'API Key (Environment)'
|
||||
: geminiUsage.authMethod === 'api_key'
|
||||
? 'API Key'
|
||||
: 'Unknown'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{geminiUsage.error ? (
|
||||
<>Quota API: {geminiUsage.error}</>
|
||||
) : (
|
||||
<>No usage yet or quota data unavailable</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500/80" />
|
||||
<p className="text-sm font-medium mt-3">Not authenticated</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Run <code className="font-mono bg-muted px-1 rounded">gemini auth login</code>{' '}
|
||||
to authenticate
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-secondary/10 border-t border-border/50">
|
||||
<a
|
||||
href="https://ai.google.dev"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||
>
|
||||
Google AI <ExternalLink className="w-2.5 h-2.5" />
|
||||
</a>
|
||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -82,6 +82,7 @@ export function BoardHeader({
|
||||
);
|
||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||
const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus);
|
||||
const geminiAuthStatus = useSetupStore((state) => state.geminiAuthStatus);
|
||||
|
||||
// Worktree panel visibility (per-project)
|
||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||
@@ -116,6 +117,9 @@ export function BoardHeader({
|
||||
// z.ai usage tracking visibility logic
|
||||
const showZaiUsage = !!zaiAuthStatus?.authenticated;
|
||||
|
||||
// Gemini usage tracking visibility logic
|
||||
const showGeminiUsage = !!geminiAuthStatus?.authenticated;
|
||||
|
||||
// State for mobile actions panel
|
||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||
@@ -163,9 +167,11 @@ export function BoardHeader({
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Usage Popover - show if any provider is authenticated, only on desktop */}
|
||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && (
|
||||
<UsagePopover />
|
||||
)}
|
||||
{isMounted &&
|
||||
!isTablet &&
|
||||
(showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && (
|
||||
<UsagePopover />
|
||||
)}
|
||||
|
||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||
{isMounted && isTablet && (
|
||||
@@ -185,6 +191,7 @@ export function BoardHeader({
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
showZaiUsage={showZaiUsage}
|
||||
showGeminiUsage={showGeminiUsage}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ interface HeaderMobileMenuProps {
|
||||
showClaudeUsage: boolean;
|
||||
showCodexUsage: boolean;
|
||||
showZaiUsage?: boolean;
|
||||
showGeminiUsage?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderMobileMenu({
|
||||
@@ -49,13 +50,14 @@ export function HeaderMobileMenu({
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
showZaiUsage = false,
|
||||
showGeminiUsage = false,
|
||||
}: HeaderMobileMenuProps) {
|
||||
return (
|
||||
<>
|
||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||
{/* Usage Bar - show if any provider is authenticated */}
|
||||
{(showClaudeUsage || showCodexUsage || showZaiUsage) && (
|
||||
{(showClaudeUsage || showCodexUsage || showZaiUsage || showGeminiUsage) && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||
Usage
|
||||
@@ -64,6 +66,7 @@ export function HeaderMobileMenu({
|
||||
showClaudeUsage={showClaudeUsage}
|
||||
showCodexUsage={showCodexUsage}
|
||||
showZaiUsage={showZaiUsage}
|
||||
showGeminiUsage={showGeminiUsage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,12 +4,14 @@ import { cn } from '@/lib/utils';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon';
|
||||
import { AnthropicIcon, OpenAIIcon, ZaiIcon, GeminiIcon } from '@/components/ui/provider-icon';
|
||||
import type { GeminiUsage } from '@/store/app-store';
|
||||
|
||||
interface MobileUsageBarProps {
|
||||
showClaudeUsage: boolean;
|
||||
showCodexUsage: boolean;
|
||||
showZaiUsage?: boolean;
|
||||
showGeminiUsage?: boolean;
|
||||
}
|
||||
|
||||
// Helper to get progress bar color based on percentage
|
||||
@@ -152,6 +154,7 @@ export function MobileUsageBar({
|
||||
showClaudeUsage,
|
||||
showCodexUsage,
|
||||
showZaiUsage = false,
|
||||
showGeminiUsage = false,
|
||||
}: MobileUsageBarProps) {
|
||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||
@@ -159,12 +162,17 @@ export function MobileUsageBar({
|
||||
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||
const [isZaiLoading, setIsZaiLoading] = useState(false);
|
||||
const [isGeminiLoading, setIsGeminiLoading] = useState(false);
|
||||
const [geminiUsage, setGeminiUsage] = useState<GeminiUsage | null>(null);
|
||||
const [geminiUsageLastUpdated, setGeminiUsageLastUpdated] = useState<number | null>(null);
|
||||
|
||||
// Check if data is stale (older than 2 minutes)
|
||||
const isClaudeStale =
|
||||
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||
const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000;
|
||||
const isGeminiStale =
|
||||
!geminiUsageLastUpdated || Date.now() - geminiUsageLastUpdated > 2 * 60 * 1000;
|
||||
|
||||
const fetchClaudeUsage = useCallback(async () => {
|
||||
setIsClaudeLoading(true);
|
||||
@@ -214,6 +222,23 @@ export function MobileUsageBar({
|
||||
}
|
||||
}, [setZaiUsage]);
|
||||
|
||||
const fetchGeminiUsage = useCallback(async () => {
|
||||
setIsGeminiLoading(true);
|
||||
try {
|
||||
const api = getElectronAPI();
|
||||
if (!api.gemini) return;
|
||||
const data = await api.gemini.getUsage();
|
||||
if (!('error' in data)) {
|
||||
setGeminiUsage(data);
|
||||
setGeminiUsageLastUpdated(Date.now());
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - usage display is optional
|
||||
} finally {
|
||||
setIsGeminiLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getCodexWindowLabel = (durationMins: number) => {
|
||||
if (durationMins < 60) return `${durationMins}m Window`;
|
||||
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
|
||||
@@ -239,8 +264,14 @@ export function MobileUsageBar({
|
||||
}
|
||||
}, [showZaiUsage, isZaiStale, fetchZaiUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showGeminiUsage && isGeminiStale) {
|
||||
fetchGeminiUsage();
|
||||
}
|
||||
}, [showGeminiUsage, isGeminiStale, fetchGeminiUsage]);
|
||||
|
||||
// Don't render if there's nothing to show
|
||||
if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) {
|
||||
if (!showClaudeUsage && !showCodexUsage && !showZaiUsage && !showGeminiUsage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -340,6 +371,58 @@ export function MobileUsageBar({
|
||||
)}
|
||||
</UsageItem>
|
||||
)}
|
||||
|
||||
{showGeminiUsage && (
|
||||
<UsageItem
|
||||
icon={GeminiIcon}
|
||||
label="Gemini"
|
||||
isLoading={isGeminiLoading}
|
||||
onRefresh={fetchGeminiUsage}
|
||||
>
|
||||
{geminiUsage ? (
|
||||
geminiUsage.authenticated ? (
|
||||
geminiUsage.flashQuota || geminiUsage.proQuota ? (
|
||||
<>
|
||||
{geminiUsage.flashQuota && (
|
||||
<UsageBar
|
||||
label="Flash"
|
||||
percentage={geminiUsage.flashQuota.usedPercent}
|
||||
isStale={isGeminiStale}
|
||||
resetText={geminiUsage.flashQuota.resetText}
|
||||
/>
|
||||
)}
|
||||
{geminiUsage.proQuota && (
|
||||
<UsageBar
|
||||
label="Pro"
|
||||
percentage={geminiUsage.proQuota.usedPercent}
|
||||
isStale={isGeminiStale}
|
||||
resetText={geminiUsage.proQuota.resetText}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-[10px]">
|
||||
<p className="text-green-500 font-medium">
|
||||
Connected via{' '}
|
||||
{geminiUsage.authMethod === 'cli_login'
|
||||
? 'CLI Login'
|
||||
: geminiUsage.authMethod === 'api_key'
|
||||
? 'API Key'
|
||||
: geminiUsage.authMethod}
|
||||
</p>
|
||||
<p className="text-muted-foreground italic mt-0.5">
|
||||
{geminiUsage.error || 'No usage yet'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-[10px] text-yellow-500">Not authenticated</p>
|
||||
)
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||
)}
|
||||
</UsageItem>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export {
|
||||
} from './use-github';
|
||||
|
||||
// Usage
|
||||
export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage';
|
||||
export { useClaudeUsage, useCodexUsage, useZaiUsage, useGeminiUsage } 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, Codex, and z.ai API usage data.
|
||||
* React Query hooks for fetching Claude, Codex, z.ai, and Gemini API usage data.
|
||||
* These hooks include automatic polling for real-time usage updates.
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { queryKeys } from '@/lib/query-keys';
|
||||
import { STALE_TIMES } from '@/lib/query-client';
|
||||
import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store';
|
||||
import type { ClaudeUsage, CodexUsage, ZaiUsage, GeminiUsage } from '@/store/app-store';
|
||||
|
||||
/** Polling interval for usage data (60 seconds) */
|
||||
const USAGE_POLLING_INTERVAL = 60 * 1000;
|
||||
@@ -33,7 +33,7 @@ export function useClaudeUsage(enabled = true) {
|
||||
queryFn: async (): Promise<ClaudeUsage> => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.claude) {
|
||||
throw new Error('Claude API not available');
|
||||
throw new Error('Claude API bridge unavailable');
|
||||
}
|
||||
const result = await api.claude.getUsage();
|
||||
// Check if result is an error response
|
||||
@@ -69,7 +69,7 @@ export function useCodexUsage(enabled = true) {
|
||||
queryFn: async (): Promise<CodexUsage> => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.codex) {
|
||||
throw new Error('Codex API not available');
|
||||
throw new Error('Codex API bridge unavailable');
|
||||
}
|
||||
const result = await api.codex.getUsage();
|
||||
// Check if result is an error response
|
||||
@@ -104,6 +104,9 @@ export function useZaiUsage(enabled = true) {
|
||||
queryKey: queryKeys.usage.zai(),
|
||||
queryFn: async (): Promise<ZaiUsage> => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.zai) {
|
||||
throw new Error('z.ai API bridge unavailable');
|
||||
}
|
||||
const result = await api.zai.getUsage();
|
||||
// Check if result is an error response
|
||||
if ('error' in result) {
|
||||
@@ -120,3 +123,37 @@ export function useZaiUsage(enabled = true) {
|
||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Gemini API usage/status data
|
||||
*
|
||||
* @param enabled - Whether the query should run (default: true)
|
||||
* @returns Query result with Gemini usage data
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { data: usage, isLoading } = useGeminiUsage(isPopoverOpen);
|
||||
* ```
|
||||
*/
|
||||
export function useGeminiUsage(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.usage.gemini(),
|
||||
queryFn: async (): Promise<GeminiUsage> => {
|
||||
const api = getElectronAPI();
|
||||
if (!api.gemini) {
|
||||
throw new Error('Gemini API bridge unavailable');
|
||||
}
|
||||
const result = await api.gemini.getUsage();
|
||||
// Server always returns a response with 'authenticated' field, even on error
|
||||
// So we can safely cast to GeminiUsage
|
||||
return result as GeminiUsage;
|
||||
},
|
||||
enabled,
|
||||
staleTime: STALE_TIMES.USAGE,
|
||||
refetchInterval: enabled ? USAGE_POLLING_INTERVAL : false,
|
||||
// Keep previous data while refetching
|
||||
placeholderData: (previousData) => previousData,
|
||||
refetchOnWindowFocus: USAGE_REFETCH_ON_FOCUS,
|
||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
type ClaudeAuthMethod,
|
||||
type CodexAuthMethod,
|
||||
type ZaiAuthMethod,
|
||||
type GeminiAuthMethod,
|
||||
} from '@/store/setup-store';
|
||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
@@ -11,7 +12,7 @@ import { createLogger } from '@automaker/utils/logger';
|
||||
const logger = createLogger('ProviderAuthInit');
|
||||
|
||||
/**
|
||||
* Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup.
|
||||
* Hook to initialize Claude, Codex, z.ai, and Gemini authentication statuses on app startup.
|
||||
* This ensures that usage tracking information is available in the board header
|
||||
* without needing to visit the settings page first.
|
||||
*/
|
||||
@@ -20,9 +21,12 @@ export function useProviderAuthInit() {
|
||||
setClaudeAuthStatus,
|
||||
setCodexAuthStatus,
|
||||
setZaiAuthStatus,
|
||||
setGeminiCliStatus,
|
||||
setGeminiAuthStatus,
|
||||
claudeAuthStatus,
|
||||
codexAuthStatus,
|
||||
zaiAuthStatus,
|
||||
geminiAuthStatus,
|
||||
} = useSetupStore();
|
||||
const initialized = useRef(false);
|
||||
|
||||
@@ -121,18 +125,74 @@ export function useProviderAuthInit() {
|
||||
} catch (error) {
|
||||
logger.error('Failed to init z.ai auth status:', error);
|
||||
}
|
||||
}, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]);
|
||||
|
||||
// 4. Gemini Auth Status
|
||||
try {
|
||||
const result = await api.setup.getGeminiStatus();
|
||||
if (result.success) {
|
||||
// Set CLI status
|
||||
setGeminiCliStatus({
|
||||
installed: result.installed ?? false,
|
||||
version: result.version,
|
||||
path: result.status,
|
||||
});
|
||||
|
||||
// Set Auth status - always set a status to mark initialization as complete
|
||||
if (result.auth) {
|
||||
const auth = result.auth;
|
||||
const validMethods: GeminiAuthMethod[] = ['cli_login', 'api_key_env', 'api_key', 'none'];
|
||||
|
||||
const method = validMethods.includes(auth.method as GeminiAuthMethod)
|
||||
? (auth.method as GeminiAuthMethod)
|
||||
: ((auth.authenticated ? 'cli_login' : 'none') as GeminiAuthMethod);
|
||||
|
||||
setGeminiAuthStatus({
|
||||
authenticated: auth.authenticated,
|
||||
method,
|
||||
hasApiKey: auth.hasApiKey ?? false,
|
||||
hasEnvApiKey: auth.hasEnvApiKey ?? false,
|
||||
});
|
||||
} else {
|
||||
// No auth info available, set default unauthenticated status
|
||||
setGeminiAuthStatus({
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey: false,
|
||||
hasEnvApiKey: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to init Gemini auth status:', error);
|
||||
// Set default status on error to prevent infinite retries
|
||||
setGeminiAuthStatus({
|
||||
authenticated: false,
|
||||
method: 'none',
|
||||
hasApiKey: false,
|
||||
hasEnvApiKey: false,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
setClaudeAuthStatus,
|
||||
setCodexAuthStatus,
|
||||
setZaiAuthStatus,
|
||||
setGeminiCliStatus,
|
||||
setGeminiAuthStatus,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only initialize once per session if not already set
|
||||
if (
|
||||
initialized.current ||
|
||||
(claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null)
|
||||
(claudeAuthStatus !== null &&
|
||||
codexAuthStatus !== null &&
|
||||
zaiAuthStatus !== null &&
|
||||
geminiAuthStatus !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
initialized.current = true;
|
||||
|
||||
void refreshStatuses();
|
||||
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]);
|
||||
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus, geminiAuthStatus]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Type definitions for Electron IPC API
|
||||
import type { SessionListItem, Message } from '@/types/electron';
|
||||
import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store';
|
||||
import type {
|
||||
ClaudeUsageResponse,
|
||||
CodexUsageResponse,
|
||||
ZaiUsageResponse,
|
||||
GeminiUsageResponse,
|
||||
} from '@/store/app-store';
|
||||
import type {
|
||||
IssueValidationVerdict,
|
||||
IssueValidationConfidence,
|
||||
@@ -874,6 +879,9 @@ export interface ElectronAPI {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
gemini?: {
|
||||
getUsage: () => Promise<GeminiUsageResponse>;
|
||||
};
|
||||
settings?: {
|
||||
getStatus: () => Promise<{
|
||||
success: boolean;
|
||||
@@ -1418,6 +1426,20 @@ const _getMockElectronAPI = (): ElectronAPI => {
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// Mock Gemini API
|
||||
gemini: {
|
||||
getUsage: async () => {
|
||||
console.log('[Mock] Getting Gemini usage');
|
||||
return {
|
||||
authenticated: true,
|
||||
authMethod: 'cli_login',
|
||||
usedPercent: 0,
|
||||
remainingPercent: 100,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -41,7 +41,11 @@ import type {
|
||||
Notification,
|
||||
} from '@automaker/types';
|
||||
import type { Message, SessionListItem } from '@/types/electron';
|
||||
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
||||
import type {
|
||||
ClaudeUsageResponse,
|
||||
CodexUsageResponse,
|
||||
GeminiUsage,
|
||||
} from '@/store/app-store';
|
||||
import type { WorktreeAPI, GitAPI, ModelDefinition, ProviderStatus } from '@/types/electron';
|
||||
import type { ModelId, ThinkingLevel, ReasoningEffort, Feature } from '@automaker/types';
|
||||
import { getGlobalFileBrowser } from '@/contexts/file-browser-context';
|
||||
@@ -2688,6 +2692,11 @@ export class HttpApiClient implements ElectronAPI {
|
||||
},
|
||||
};
|
||||
|
||||
// Gemini API
|
||||
gemini = {
|
||||
getUsage: (): Promise<GeminiUsage> => this.get('/api/gemini/usage'),
|
||||
};
|
||||
|
||||
// Context API
|
||||
context = {
|
||||
describeImage: (
|
||||
|
||||
@@ -101,6 +101,8 @@ export const queryKeys = {
|
||||
codex: () => ['usage', 'codex'] as const,
|
||||
/** z.ai API usage */
|
||||
zai: () => ['usage', 'zai'] as const,
|
||||
/** Gemini API usage */
|
||||
gemini: () => ['usage', 'gemini'] as const,
|
||||
},
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -98,6 +98,10 @@ import {
|
||||
type ZaiQuotaLimit,
|
||||
type ZaiUsage,
|
||||
type ZaiUsageResponse,
|
||||
type GeminiQuotaBucket,
|
||||
type GeminiTierQuota,
|
||||
type GeminiUsage,
|
||||
type GeminiUsageResponse,
|
||||
} from './types';
|
||||
|
||||
// Import utility functions from modular utils files
|
||||
@@ -181,6 +185,10 @@ export type {
|
||||
ZaiQuotaLimit,
|
||||
ZaiUsage,
|
||||
ZaiUsageResponse,
|
||||
GeminiQuotaBucket,
|
||||
GeminiTierQuota,
|
||||
GeminiUsage,
|
||||
GeminiUsageResponse,
|
||||
};
|
||||
|
||||
// Re-export values from ./types for backward compatibility
|
||||
@@ -210,7 +218,7 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES
|
||||
// - Terminal types (./types/terminal-types.ts)
|
||||
// - ClaudeModel, Feature, FileTreeNode, ProjectAnalysis (./types/project-types.ts)
|
||||
// - InitScriptState, AutoModeActivity, AppState, AppActions (./types/state-types.ts)
|
||||
// - Claude/Codex usage types (./types/usage-types.ts)
|
||||
// - Claude/Codex/Zai/Gemini usage types (./types/usage-types.ts)
|
||||
// The following utility functions have been moved to ./utils/:
|
||||
// - Theme utilities: THEME_STORAGE_KEY, getStoredTheme, getStoredFontSans, getStoredFontMono, etc. (./utils/theme-utils.ts)
|
||||
// - Shortcut utilities: parseShortcut, formatShortcut, DEFAULT_KEYBOARD_SHORTCUTS (./utils/shortcut-utils.ts)
|
||||
@@ -220,6 +228,9 @@ export { defaultBackgroundSettings, defaultTerminalState, MAX_INIT_OUTPUT_LINES
|
||||
// - defaultBackgroundSettings (./defaults/background-settings.ts)
|
||||
// - defaultTerminalState (./defaults/terminal-defaults.ts)
|
||||
|
||||
// Type definitions are imported from ./types/state-types.ts
|
||||
// AppActions interface is defined in ./types/state-types.ts
|
||||
|
||||
const initialState: AppState = {
|
||||
projects: [],
|
||||
currentProject: null,
|
||||
|
||||
@@ -127,6 +127,22 @@ export interface ZaiAuthStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Gemini Auth Method
|
||||
export type GeminiAuthMethod =
|
||||
| 'cli_login' // Gemini CLI is installed and authenticated
|
||||
| 'api_key_env' // GOOGLE_API_KEY or GEMINI_API_KEY environment variable
|
||||
| 'api_key' // Manually stored API key
|
||||
| 'none';
|
||||
|
||||
// Gemini Auth Status
|
||||
export interface GeminiAuthStatus {
|
||||
authenticated: boolean;
|
||||
method: GeminiAuthMethod;
|
||||
hasApiKey?: boolean;
|
||||
hasEnvApiKey?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Claude Auth Method - all possible authentication sources
|
||||
export type ClaudeAuthMethod =
|
||||
| 'oauth_token_env'
|
||||
@@ -200,6 +216,7 @@ export interface SetupState {
|
||||
|
||||
// Gemini CLI state
|
||||
geminiCliStatus: GeminiCliStatus | null;
|
||||
geminiAuthStatus: GeminiAuthStatus | null;
|
||||
|
||||
// Copilot SDK state
|
||||
copilotCliStatus: CopilotCliStatus | null;
|
||||
@@ -243,6 +260,7 @@ export interface SetupActions {
|
||||
|
||||
// Gemini CLI
|
||||
setGeminiCliStatus: (status: GeminiCliStatus | null) => void;
|
||||
setGeminiAuthStatus: (status: GeminiAuthStatus | null) => void;
|
||||
|
||||
// Copilot SDK
|
||||
setCopilotCliStatus: (status: CopilotCliStatus | null) => void;
|
||||
@@ -284,6 +302,7 @@ const initialState: SetupState = {
|
||||
opencodeCliStatus: null,
|
||||
|
||||
geminiCliStatus: null,
|
||||
geminiAuthStatus: null,
|
||||
|
||||
copilotCliStatus: null,
|
||||
|
||||
@@ -363,6 +382,7 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
||||
|
||||
// Gemini CLI
|
||||
setGeminiCliStatus: (status) => set({ geminiCliStatus: status }),
|
||||
setGeminiAuthStatus: (status) => set({ geminiAuthStatus: status }),
|
||||
|
||||
// Copilot SDK
|
||||
setCopilotCliStatus: (status) => set({ copilotCliStatus: status }),
|
||||
|
||||
@@ -82,3 +82,55 @@ export interface ZaiUsage {
|
||||
|
||||
// Response type for z.ai usage API (can be success or error)
|
||||
export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string };
|
||||
|
||||
// Gemini Usage types - uses internal Google Cloud quota API
|
||||
export interface GeminiQuotaBucket {
|
||||
/** Model ID this quota applies to */
|
||||
modelId: string;
|
||||
/** Remaining fraction (0-1) */
|
||||
remainingFraction: number;
|
||||
/** ISO-8601 reset time */
|
||||
resetTime: string;
|
||||
}
|
||||
|
||||
/** Simplified quota info for a model tier (Flash or Pro) */
|
||||
export interface GeminiTierQuota {
|
||||
/** Used percentage (0-100) */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage (0-100) */
|
||||
remainingPercent: number;
|
||||
/** Reset time as human-readable string */
|
||||
resetText?: string;
|
||||
/** ISO-8601 reset time */
|
||||
resetTime?: string;
|
||||
}
|
||||
|
||||
export interface GeminiUsage {
|
||||
/** Whether the user is authenticated (via CLI or API key) */
|
||||
authenticated: boolean;
|
||||
/** Authentication method: 'cli_login' | 'api_key' | 'api_key_env' | 'none' */
|
||||
authMethod: string;
|
||||
/** Usage percentage (100 - remainingFraction * 100) - overall most constrained */
|
||||
usedPercent: number;
|
||||
/** Remaining percentage - overall most constrained */
|
||||
remainingPercent: number;
|
||||
/** Reset time as human-readable string */
|
||||
resetText?: string;
|
||||
/** ISO-8601 reset time */
|
||||
resetTime?: string;
|
||||
/** Model ID with lowest remaining quota */
|
||||
constrainedModel?: string;
|
||||
/** Flash tier quota (aggregated from all flash models) */
|
||||
flashQuota?: GeminiTierQuota;
|
||||
/** Pro tier quota (aggregated from all pro models) */
|
||||
proQuota?: GeminiTierQuota;
|
||||
/** Raw quota buckets for detailed view */
|
||||
quotaBuckets?: GeminiQuotaBucket[];
|
||||
/** When this data was last fetched */
|
||||
lastUpdated: string;
|
||||
/** Optional error message */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Response type for Gemini usage API (can be success or error)
|
||||
export type GeminiUsageResponse = GeminiUsage | { error: string; message?: string };
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -11475,7 +11475,6 @@
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11497,7 +11496,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11519,7 +11517,6 @@
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11541,7 +11538,6 @@
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11563,7 +11559,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11585,7 +11580,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11607,7 +11601,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11629,7 +11622,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11651,7 +11643,6 @@
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11673,7 +11664,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
@@ -11695,7 +11685,6 @@
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user