mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-22 11:43:07 +00:00
Feat: Add z.ai usage tracking
This commit is contained in:
@@ -66,6 +66,8 @@ import { createCodexRoutes } from './routes/codex/index.js';
|
|||||||
import { CodexUsageService } from './services/codex-usage-service.js';
|
import { CodexUsageService } from './services/codex-usage-service.js';
|
||||||
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
import { CodexAppServerService } from './services/codex-app-server-service.js';
|
||||||
import { CodexModelCacheService } from './services/codex-model-cache-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 { createGitHubRoutes } from './routes/github/index.js';
|
import { createGitHubRoutes } from './routes/github/index.js';
|
||||||
import { createContextRoutes } from './routes/context/index.js';
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||||
@@ -326,6 +328,7 @@ const claudeUsageService = new ClaudeUsageService();
|
|||||||
const codexAppServerService = new CodexAppServerService();
|
const codexAppServerService = new CodexAppServerService();
|
||||||
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService);
|
||||||
const codexUsageService = new CodexUsageService(codexAppServerService);
|
const codexUsageService = new CodexUsageService(codexAppServerService);
|
||||||
|
const zaiUsageService = new ZaiUsageService();
|
||||||
const mcpTestService = new MCPTestService(settingsService);
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||||
|
|
||||||
@@ -434,6 +437,7 @@ app.use('/api/terminal', createTerminalRoutes());
|
|||||||
app.use('/api/settings', createSettingsRoutes(settingsService));
|
app.use('/api/settings', createSettingsRoutes(settingsService));
|
||||||
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
||||||
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService));
|
||||||
|
app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService));
|
||||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||||
app.use('/api/context', createContextRoutes(settingsService));
|
app.use('/api/context', createContextRoutes(settingsService));
|
||||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||||
|
|||||||
179
apps/server/src/routes/zai/index.ts
Normal file
179
apps/server/src/routes/zai/index.ts
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import { ZaiUsageService } from '../../services/zai-usage-service.js';
|
||||||
|
import type { SettingsService } from '../../services/settings-service.js';
|
||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('Zai');
|
||||||
|
|
||||||
|
export function createZaiRoutes(
|
||||||
|
usageService: ZaiUsageService,
|
||||||
|
settingsService: SettingsService
|
||||||
|
): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Initialize z.ai API token from credentials on startup
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const credentials = await settingsService.getCredentials();
|
||||||
|
if (credentials.apiKeys?.zai) {
|
||||||
|
usageService.setApiToken(credentials.apiKeys.zai);
|
||||||
|
logger.info('[init] Loaded z.ai API key from credentials');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[init] Failed to load z.ai API key from credentials:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Get current usage (fetches from z.ai API)
|
||||||
|
router.get('/usage', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
// Check if z.ai API is configured
|
||||||
|
const isAvailable = usageService.isAvailable();
|
||||||
|
if (!isAvailable) {
|
||||||
|
// Use a 200 + error payload so the UI doesn't interpret it as session auth error
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'z.ai API not configured',
|
||||||
|
message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const usage = await usageService.fetchUsageData();
|
||||||
|
res.json(usage);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
|
||||||
|
if (message.includes('not configured') || message.includes('API token')) {
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'API token required',
|
||||||
|
message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking',
|
||||||
|
});
|
||||||
|
} else if (message.includes('failed') || message.includes('request')) {
|
||||||
|
res.status(200).json({
|
||||||
|
error: 'API request failed',
|
||||||
|
message: message,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logger.error('Error fetching z.ai usage:', error);
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Configure API token (for settings page)
|
||||||
|
router.post('/configure', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { apiToken, apiHost } = req.body;
|
||||||
|
|
||||||
|
if (apiToken !== undefined) {
|
||||||
|
// Set in-memory token
|
||||||
|
usageService.setApiToken(apiToken || '');
|
||||||
|
|
||||||
|
// Persist to credentials (deep merge happens in updateCredentials)
|
||||||
|
try {
|
||||||
|
await settingsService.updateCredentials({
|
||||||
|
apiKeys: { zai: apiToken || '' },
|
||||||
|
} as Parameters<typeof settingsService.updateCredentials>[0]);
|
||||||
|
logger.info('[configure] Saved z.ai API key to credentials');
|
||||||
|
} catch (persistError) {
|
||||||
|
logger.error('[configure] Failed to persist z.ai API key:', persistError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiHost) {
|
||||||
|
usageService.setApiHost(apiHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'z.ai configuration updated',
|
||||||
|
isAvailable: usageService.isAvailable(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error configuring z.ai:', error);
|
||||||
|
res.status(500).json({ error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify API key without storing it (for testing in settings)
|
||||||
|
router.post('/verify', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { apiKey } = req.body;
|
||||||
|
|
||||||
|
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: 'Please provide an API key to test.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the key by making a request to z.ai API
|
||||||
|
const quotaUrl =
|
||||||
|
process.env.Z_AI_QUOTA_URL ||
|
||||||
|
`${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`;
|
||||||
|
|
||||||
|
logger.info(`[verify] Testing API key against: ${quotaUrl}`);
|
||||||
|
|
||||||
|
const response = await fetch(quotaUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey.trim()}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
authenticated: true,
|
||||||
|
message: 'Connection successful! z.ai API responded.',
|
||||||
|
});
|
||||||
|
} else if (response.status === 401 || response.status === 403) {
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: 'Invalid API key. Please check your key and try again.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: `API request failed: ${response.status} ${response.statusText}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
logger.error('Error verifying z.ai API key:', error);
|
||||||
|
res.json({
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: `Network error: ${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if z.ai is available
|
||||||
|
router.get('/status', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const isAvailable = usageService.isAvailable();
|
||||||
|
const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY);
|
||||||
|
const hasApiKey = usageService.getApiToken() !== null;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
available: isAvailable,
|
||||||
|
hasApiKey,
|
||||||
|
hasEnvApiKey,
|
||||||
|
message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
res.status(500).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -1018,6 +1018,7 @@ export class SettingsService {
|
|||||||
anthropic: apiKeys.anthropic || '',
|
anthropic: apiKeys.anthropic || '',
|
||||||
google: apiKeys.google || '',
|
google: apiKeys.google || '',
|
||||||
openai: apiKeys.openai || '',
|
openai: apiKeys.openai || '',
|
||||||
|
zai: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
migratedCredentials = true;
|
migratedCredentials = true;
|
||||||
|
|||||||
375
apps/server/src/services/zai-usage-service.ts
Normal file
375
apps/server/src/services/zai-usage-service.ts
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import { createLogger } from '@automaker/utils';
|
||||||
|
|
||||||
|
const logger = createLogger('ZaiUsage');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai quota limit entry from the API
|
||||||
|
*/
|
||||||
|
export interface ZaiQuotaLimit {
|
||||||
|
limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string;
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
usedPercent: number;
|
||||||
|
nextResetTime: number; // epoch milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai usage details by model (for MCP tracking)
|
||||||
|
*/
|
||||||
|
export interface ZaiUsageDetail {
|
||||||
|
modelId: string;
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai plan types
|
||||||
|
*/
|
||||||
|
export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai usage data structure
|
||||||
|
*/
|
||||||
|
export interface ZaiUsageData {
|
||||||
|
quotaLimits: {
|
||||||
|
tokens?: ZaiQuotaLimit;
|
||||||
|
mcp?: ZaiQuotaLimit;
|
||||||
|
planType: ZaiPlanType;
|
||||||
|
} | null;
|
||||||
|
usageDetails?: ZaiUsageDetail[];
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai API limit entry - supports multiple field naming conventions
|
||||||
|
*/
|
||||||
|
interface ZaiApiLimit {
|
||||||
|
// Type field (z.ai uses 'type', others might use 'limitType')
|
||||||
|
type?: string;
|
||||||
|
limitType?: string;
|
||||||
|
// Limit value (z.ai uses 'usage' for total limit, others might use 'limit')
|
||||||
|
usage?: number;
|
||||||
|
limit?: number;
|
||||||
|
// Used value (z.ai uses 'currentValue', others might use 'used')
|
||||||
|
currentValue?: number;
|
||||||
|
used?: number;
|
||||||
|
// Remaining
|
||||||
|
remaining?: number;
|
||||||
|
// Percentage (z.ai uses 'percentage', others might use 'usedPercent')
|
||||||
|
percentage?: number;
|
||||||
|
usedPercent?: number;
|
||||||
|
// Reset time
|
||||||
|
nextResetTime?: number;
|
||||||
|
// Additional z.ai fields
|
||||||
|
unit?: number;
|
||||||
|
number?: number;
|
||||||
|
usageDetails?: Array<{ modelCode: string; usage: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai API response structure
|
||||||
|
* Flexible to handle various possible response formats
|
||||||
|
*/
|
||||||
|
interface ZaiApiResponse {
|
||||||
|
code?: number;
|
||||||
|
success?: boolean;
|
||||||
|
data?: {
|
||||||
|
limits?: ZaiApiLimit[];
|
||||||
|
// Alternative: limits might be an object instead of array
|
||||||
|
tokensLimit?: {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining?: number;
|
||||||
|
usedPercent?: number;
|
||||||
|
nextResetTime?: number;
|
||||||
|
};
|
||||||
|
timeLimit?: {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining?: number;
|
||||||
|
usedPercent?: number;
|
||||||
|
nextResetTime?: number;
|
||||||
|
};
|
||||||
|
// Quota-style fields
|
||||||
|
quota?: number;
|
||||||
|
quotaUsed?: number;
|
||||||
|
quotaRemaining?: number;
|
||||||
|
planName?: string;
|
||||||
|
plan?: string;
|
||||||
|
plan_type?: string;
|
||||||
|
packageName?: string;
|
||||||
|
usageDetails?: Array<{
|
||||||
|
modelId: string;
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
// Root-level alternatives
|
||||||
|
limits?: ZaiApiLimit[];
|
||||||
|
quota?: number;
|
||||||
|
quotaUsed?: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* z.ai Usage Service
|
||||||
|
*
|
||||||
|
* Fetches usage quota data from the z.ai API.
|
||||||
|
* Uses API token authentication stored via environment variable or settings.
|
||||||
|
*/
|
||||||
|
export class ZaiUsageService {
|
||||||
|
private apiToken: string | null = null;
|
||||||
|
private apiHost: string = 'https://api.z.ai';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the API token for authentication
|
||||||
|
*/
|
||||||
|
setApiToken(token: string): void {
|
||||||
|
this.apiToken = token;
|
||||||
|
logger.info('[setApiToken] API token configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current API token
|
||||||
|
*/
|
||||||
|
getApiToken(): string | null {
|
||||||
|
// Priority: 1. Instance token, 2. Environment variable
|
||||||
|
return this.apiToken || process.env.Z_AI_API_KEY || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the API host (for BigModel CN region support)
|
||||||
|
*/
|
||||||
|
setApiHost(host: string): void {
|
||||||
|
this.apiHost = host.startsWith('http') ? host : `https://${host}`;
|
||||||
|
logger.info(`[setApiHost] API host set to: ${this.apiHost}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the API host
|
||||||
|
*/
|
||||||
|
getApiHost(): string {
|
||||||
|
// Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default
|
||||||
|
return process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : this.apiHost;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if z.ai API is available (has token configured)
|
||||||
|
*/
|
||||||
|
isAvailable(): boolean {
|
||||||
|
const token = this.getApiToken();
|
||||||
|
return Boolean(token && token.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch usage data from z.ai API
|
||||||
|
*/
|
||||||
|
async fetchUsageData(): Promise<ZaiUsageData> {
|
||||||
|
logger.info('[fetchUsageData] Starting...');
|
||||||
|
|
||||||
|
const token = this.getApiToken();
|
||||||
|
if (!token) {
|
||||||
|
logger.error('[fetchUsageData] No API token configured');
|
||||||
|
throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaUrl =
|
||||||
|
process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`;
|
||||||
|
|
||||||
|
logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(quotaUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as unknown as ZaiApiResponse;
|
||||||
|
logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
return this.parseApiResponse(data);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('z.ai API')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.error('[fetchUsageData] Failed to fetch:', error);
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the z.ai API response into our data structure
|
||||||
|
* Handles multiple possible response formats from z.ai API
|
||||||
|
*/
|
||||||
|
private parseApiResponse(response: ZaiApiResponse): ZaiUsageData {
|
||||||
|
const result: ZaiUsageData = {
|
||||||
|
quotaLimits: {
|
||||||
|
planType: 'unknown',
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2));
|
||||||
|
|
||||||
|
// Try to find data - could be in response.data or at root level
|
||||||
|
let data = response.data;
|
||||||
|
|
||||||
|
// Check for root-level limits array
|
||||||
|
if (!data && response.limits) {
|
||||||
|
logger.info('[parseApiResponse] Found limits at root level');
|
||||||
|
data = { limits: response.limits };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for root-level quota fields
|
||||||
|
if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) {
|
||||||
|
logger.info('[parseApiResponse] Found quota fields at root level');
|
||||||
|
data = { quota: response.quota, quotaUsed: response.quotaUsed };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
logger.warn('[parseApiResponse] No data found in response');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[parseApiResponse] Data keys:', Object.keys(data));
|
||||||
|
|
||||||
|
// Parse plan type from various possible field names
|
||||||
|
const planName = data.planName || data.plan || data.plan_type || data.packageName;
|
||||||
|
|
||||||
|
if (planName) {
|
||||||
|
const normalizedPlan = String(planName).toLowerCase();
|
||||||
|
if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) {
|
||||||
|
result.quotaLimits!.planType = normalizedPlan as ZaiPlanType;
|
||||||
|
}
|
||||||
|
logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse quota limits from array format
|
||||||
|
if (data.limits && Array.isArray(data.limits)) {
|
||||||
|
logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries');
|
||||||
|
for (const limit of data.limits) {
|
||||||
|
logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit));
|
||||||
|
|
||||||
|
// Handle different field naming conventions from z.ai API:
|
||||||
|
// - 'usage' is the total limit, 'currentValue' is the used amount
|
||||||
|
// - OR 'limit' is the total limit, 'used' is the used amount
|
||||||
|
const limitVal = limit.usage ?? limit.limit ?? 0;
|
||||||
|
const usedVal = limit.currentValue ?? limit.used ?? 0;
|
||||||
|
|
||||||
|
// Get percentage from 'percentage' or 'usedPercent' field, or calculate it
|
||||||
|
const apiPercent = limit.percentage ?? limit.usedPercent;
|
||||||
|
const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0;
|
||||||
|
const usedPercent =
|
||||||
|
apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent;
|
||||||
|
|
||||||
|
// Get limit type from 'type' or 'limitType' field
|
||||||
|
const rawLimitType = limit.type ?? limit.limitType ?? '';
|
||||||
|
|
||||||
|
const quotaLimit: ZaiQuotaLimit = {
|
||||||
|
limitType: rawLimitType || 'TOKENS_LIMIT',
|
||||||
|
limit: limitVal,
|
||||||
|
used: usedVal,
|
||||||
|
remaining: limit.remaining ?? limitVal - usedVal,
|
||||||
|
usedPercent,
|
||||||
|
nextResetTime: limit.nextResetTime ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Match various possible limitType values
|
||||||
|
const limitType = String(rawLimitType).toUpperCase();
|
||||||
|
if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') {
|
||||||
|
result.quotaLimits!.tokens = quotaLimit;
|
||||||
|
logger.info(
|
||||||
|
`[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)`
|
||||||
|
);
|
||||||
|
} else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') {
|
||||||
|
result.quotaLimits!.mcp = quotaLimit;
|
||||||
|
logger.info(
|
||||||
|
`[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// If limitType is unknown, use as tokens by default (first one)
|
||||||
|
if (!result.quotaLimits!.tokens) {
|
||||||
|
quotaLimit.limitType = 'TOKENS_LIMIT';
|
||||||
|
result.quotaLimits!.tokens = quotaLimit;
|
||||||
|
logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse alternative object-style limits
|
||||||
|
if (data.tokensLimit) {
|
||||||
|
const t = data.tokensLimit;
|
||||||
|
const limitVal = t.limit ?? 0;
|
||||||
|
const usedVal = t.used ?? 0;
|
||||||
|
const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0;
|
||||||
|
result.quotaLimits!.tokens = {
|
||||||
|
limitType: 'TOKENS_LIMIT',
|
||||||
|
limit: limitVal,
|
||||||
|
used: usedVal,
|
||||||
|
remaining: t.remaining ?? limitVal - usedVal,
|
||||||
|
usedPercent:
|
||||||
|
t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent,
|
||||||
|
nextResetTime: t.nextResetTime ?? 0,
|
||||||
|
};
|
||||||
|
logger.info('[parseApiResponse] Parsed tokensLimit object');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.timeLimit) {
|
||||||
|
const t = data.timeLimit;
|
||||||
|
const limitVal = t.limit ?? 0;
|
||||||
|
const usedVal = t.used ?? 0;
|
||||||
|
const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0;
|
||||||
|
result.quotaLimits!.mcp = {
|
||||||
|
limitType: 'TIME_LIMIT',
|
||||||
|
limit: limitVal,
|
||||||
|
used: usedVal,
|
||||||
|
remaining: t.remaining ?? limitVal - usedVal,
|
||||||
|
usedPercent:
|
||||||
|
t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent,
|
||||||
|
nextResetTime: t.nextResetTime ?? 0,
|
||||||
|
};
|
||||||
|
logger.info('[parseApiResponse] Parsed timeLimit object');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse simple quota/quotaUsed format as tokens
|
||||||
|
if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) {
|
||||||
|
const limitVal = Number(data.quota) || 0;
|
||||||
|
const usedVal = Number(data.quotaUsed) || 0;
|
||||||
|
result.quotaLimits!.tokens = {
|
||||||
|
limitType: 'TOKENS_LIMIT',
|
||||||
|
limit: limitVal,
|
||||||
|
used: usedVal,
|
||||||
|
remaining:
|
||||||
|
data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal,
|
||||||
|
usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0,
|
||||||
|
nextResetTime: 0,
|
||||||
|
};
|
||||||
|
logger.info('[parseApiResponse] Parsed simple quota format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse usage details (MCP tracking)
|
||||||
|
if (data.usageDetails && Array.isArray(data.usageDetails)) {
|
||||||
|
result.usageDetails = data.usageDetails.map((detail) => ({
|
||||||
|
modelId: detail.modelId,
|
||||||
|
used: detail.used,
|
||||||
|
limit: detail.limit,
|
||||||
|
}));
|
||||||
|
logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
|
|||||||
},
|
},
|
||||||
glm: {
|
glm: {
|
||||||
viewBox: '0 0 24 24',
|
viewBox: '0 0 24 24',
|
||||||
// Official Z.ai logo from lobehub/lobe-icons (GLM provider)
|
// Official Z.ai/GLM logo from lobehub/lobe-icons (GLM/Zhipu provider)
|
||||||
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
|
path: 'M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z',
|
||||||
|
fill: '#3B82F6', // z.ai brand blue
|
||||||
},
|
},
|
||||||
bigpickle: {
|
bigpickle: {
|
||||||
viewBox: '0 0 24 24',
|
viewBox: '0 0 24 24',
|
||||||
@@ -391,12 +392,15 @@ export function GlmIcon({ className, title, ...props }: { className?: string; ti
|
|||||||
{title && <title>{title}</title>}
|
{title && <title>{title}</title>}
|
||||||
<path
|
<path
|
||||||
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
|
d="M12.105 2L9.927 4.953H.653L2.83 2h9.276zM23.254 19.048L21.078 22h-9.242l2.174-2.952h9.244zM24 2L9.264 22H0L14.736 2H24z"
|
||||||
fill="currentColor"
|
fill="#3B82F6"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Z.ai icon is the same as GLM (Zhipu AI)
|
||||||
|
export const ZaiIcon = GlmIcon;
|
||||||
|
|
||||||
export function BigPickleIcon({
|
export function BigPickleIcon({
|
||||||
className,
|
className,
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSetupStore } from '@/store/setup-store';
|
import { useSetupStore } from '@/store/setup-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon';
|
||||||
import { useClaudeUsage, useCodexUsage } from '@/hooks/queries';
|
import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries';
|
||||||
|
|
||||||
// Error codes for distinguishing failure modes
|
// Error codes for distinguishing failure modes
|
||||||
const ERROR_CODES = {
|
const ERROR_CODES = {
|
||||||
@@ -27,9 +27,9 @@ type UsageError = {
|
|||||||
|
|
||||||
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
const CLAUDE_SESSION_WINDOW_HOURS = 5;
|
||||||
|
|
||||||
// Helper to format reset time for Codex
|
// Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds)
|
||||||
function formatCodexResetTime(unixTimestamp: number): string {
|
function formatResetTime(unixTimestamp: number, isMilliseconds = false): string {
|
||||||
const date = new Date(unixTimestamp * 1000);
|
const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = date.getTime() - now.getTime();
|
const diff = date.getTime() - now.getTime();
|
||||||
|
|
||||||
@@ -45,6 +45,11 @@ function formatCodexResetTime(unixTimestamp: number): string {
|
|||||||
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
return `Resets ${date.toLocaleDateString()} at ${date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy alias for Codex
|
||||||
|
function formatCodexResetTime(unixTimestamp: number): string {
|
||||||
|
return formatResetTime(unixTimestamp, false);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to format window duration for Codex
|
// Helper to format window duration for Codex
|
||||||
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } {
|
||||||
if (durationMins < 60) {
|
if (durationMins < 60) {
|
||||||
@@ -58,16 +63,32 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s
|
|||||||
return { title: `${days}d Window`, subtitle: 'Rate limit' };
|
return { title: `${days}d Window`, subtitle: 'Rate limit' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to format large numbers with K/M suffixes
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) {
|
||||||
|
return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
}
|
||||||
|
if (num >= 1_000_000) {
|
||||||
|
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (num >= 1_000) {
|
||||||
|
return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export function UsagePopover() {
|
export function UsagePopover() {
|
||||||
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus);
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude');
|
const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude');
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated;
|
||||||
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
const isCodexAuthenticated = codexAuthStatus?.authenticated;
|
||||||
|
const isZaiAuthenticated = zaiAuthStatus?.authenticated;
|
||||||
|
|
||||||
// Use React Query hooks for usage data
|
// Use React Query hooks for usage data
|
||||||
// Only enable polling when popover is open AND the tab is active
|
// Only enable polling when popover is open AND the tab is active
|
||||||
@@ -87,6 +108,14 @@ export function UsagePopover() {
|
|||||||
refetch: refetchCodex,
|
refetch: refetchCodex,
|
||||||
} = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
|
} = useCodexUsage(open && activeTab === 'codex' && isCodexAuthenticated);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: zaiUsage,
|
||||||
|
isLoading: zaiLoading,
|
||||||
|
error: zaiQueryError,
|
||||||
|
dataUpdatedAt: zaiUsageLastUpdated,
|
||||||
|
refetch: refetchZai,
|
||||||
|
} = useZaiUsage(open && activeTab === 'zai' && isZaiAuthenticated);
|
||||||
|
|
||||||
// Parse errors into structured format
|
// Parse errors into structured format
|
||||||
const claudeError = useMemo((): UsageError | null => {
|
const claudeError = useMemo((): UsageError | null => {
|
||||||
if (!claudeQueryError) return null;
|
if (!claudeQueryError) return null;
|
||||||
@@ -116,14 +145,28 @@ export function UsagePopover() {
|
|||||||
return { code: ERROR_CODES.AUTH_ERROR, message };
|
return { code: ERROR_CODES.AUTH_ERROR, message };
|
||||||
}, [codexQueryError]);
|
}, [codexQueryError]);
|
||||||
|
|
||||||
|
const zaiError = useMemo((): UsageError | null => {
|
||||||
|
if (!zaiQueryError) return null;
|
||||||
|
const message = zaiQueryError instanceof Error ? zaiQueryError.message : String(zaiQueryError);
|
||||||
|
if (message.includes('not configured') || message.includes('API token')) {
|
||||||
|
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 };
|
||||||
|
}, [zaiQueryError]);
|
||||||
|
|
||||||
// Determine which tab to show by default
|
// Determine which tab to show by default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isClaudeAuthenticated) {
|
if (isClaudeAuthenticated) {
|
||||||
setActiveTab('claude');
|
setActiveTab('claude');
|
||||||
} else if (isCodexAuthenticated) {
|
} else if (isCodexAuthenticated) {
|
||||||
setActiveTab('codex');
|
setActiveTab('codex');
|
||||||
|
} else if (isZaiAuthenticated) {
|
||||||
|
setActiveTab('zai');
|
||||||
}
|
}
|
||||||
}, [isClaudeAuthenticated, isCodexAuthenticated]);
|
}, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale = useMemo(() => {
|
const isClaudeStale = useMemo(() => {
|
||||||
@@ -134,9 +177,14 @@ export function UsagePopover() {
|
|||||||
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||||
}, [codexUsageLastUpdated]);
|
}, [codexUsageLastUpdated]);
|
||||||
|
|
||||||
|
const isZaiStale = useMemo(() => {
|
||||||
|
return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000;
|
||||||
|
}, [zaiUsageLastUpdated]);
|
||||||
|
|
||||||
// Refetch functions for manual refresh
|
// Refetch functions for manual refresh
|
||||||
const fetchClaudeUsage = () => refetchClaude();
|
const fetchClaudeUsage = () => refetchClaude();
|
||||||
const fetchCodexUsage = () => refetchCodex();
|
const fetchCodexUsage = () => refetchCodex();
|
||||||
|
const fetchZaiUsage = () => refetchZai();
|
||||||
|
|
||||||
// Derived status color/icon helper
|
// Derived status color/icon helper
|
||||||
const getStatusInfo = (percentage: number) => {
|
const getStatusInfo = (percentage: number) => {
|
||||||
@@ -251,26 +299,33 @@ export function UsagePopover() {
|
|||||||
const indicatorInfo =
|
const indicatorInfo =
|
||||||
activeTab === 'claude'
|
activeTab === 'claude'
|
||||||
? {
|
? {
|
||||||
icon: AnthropicIcon,
|
icon: AnthropicIcon,
|
||||||
percentage: claudeSessionPercentage,
|
percentage: claudeSessionPercentage,
|
||||||
isStale: isClaudeStale,
|
isStale: isClaudeStale,
|
||||||
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`,
|
||||||
}
|
}
|
||||||
: {
|
: activeTab === 'codex' ? {
|
||||||
icon: OpenAIIcon,
|
icon: OpenAIIcon,
|
||||||
percentage: codexWindowUsage ?? 0,
|
percentage: codexWindowUsage ?? 0,
|
||||||
isStale: isCodexStale,
|
isStale: isCodexStale,
|
||||||
title: `Usage (${codexWindowLabel})`,
|
title: `Usage (${codexWindowLabel})`,
|
||||||
};
|
} : activeTab === 'zai' ? {
|
||||||
|
icon: ZaiIcon,
|
||||||
|
percentage: zaiMaxPercentage,
|
||||||
|
isStale: isZaiStale,
|
||||||
|
title: `Usage (z.ai)`,
|
||||||
|
} : null;
|
||||||
|
|
||||||
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
const statusColor = getStatusInfo(indicatorInfo.percentage).color;
|
||||||
const ProviderIcon = indicatorInfo.icon;
|
const ProviderIcon = indicatorInfo.icon;
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
<Button variant="ghost" size="sm" className="h-9 gap-2 bg-secondary border border-border px-3">
|
||||||
{(claudeUsage || codexUsage) && <ProviderIcon className={cn('w-4 h-4', statusColor)} />}
|
{(claudeUsage || codexUsage || zaiUsage) && (
|
||||||
|
<ProviderIcon className={cn('w-4 h-4', statusColor)} />
|
||||||
|
)}
|
||||||
<span className="text-sm font-medium">Usage</span>
|
<span className="text-sm font-medium">Usage</span>
|
||||||
{(claudeUsage || codexUsage) && (
|
{(claudeUsage || codexUsage || zaiUsage) && (
|
||||||
<div
|
<div
|
||||||
title={indicatorInfo.title}
|
title={indicatorInfo.title}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -293,6 +348,8 @@ export function UsagePopover() {
|
|||||||
// Determine which tabs to show
|
// Determine which tabs to show
|
||||||
const showClaudeTab = isClaudeAuthenticated;
|
const showClaudeTab = isClaudeAuthenticated;
|
||||||
const showCodexTab = isCodexAuthenticated;
|
const showCodexTab = isCodexAuthenticated;
|
||||||
|
const showZaiTab = isZaiAuthenticated;
|
||||||
|
const tabCount = [showClaudeTab, showCodexTab, showZaiTab].filter(Boolean).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
@@ -302,18 +359,37 @@ export function UsagePopover() {
|
|||||||
align="end"
|
align="end"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
>
|
>
|
||||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'claude' | 'codex')}>
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(v) => setActiveTab(v as 'claude' | 'codex' | 'zai')}
|
||||||
|
>
|
||||||
{/* Tabs Header */}
|
{/* Tabs Header */}
|
||||||
{showClaudeTab && showCodexTab && (
|
{tabCount > 1 && (
|
||||||
<TabsList className="grid w-full grid-cols-2 rounded-none border-b border-border/50">
|
<TabsList
|
||||||
<TabsTrigger value="claude" className="gap-2">
|
className={cn(
|
||||||
<AnthropicIcon className="w-3.5 h-3.5" />
|
'grid w-full rounded-none border-b border-border/50',
|
||||||
Claude
|
tabCount === 2 && 'grid-cols-2',
|
||||||
</TabsTrigger>
|
tabCount === 3 && 'grid-cols-3'
|
||||||
<TabsTrigger value="codex" className="gap-2">
|
)}
|
||||||
<OpenAIIcon className="w-3.5 h-3.5" />
|
>
|
||||||
Codex
|
{showClaudeTab && (
|
||||||
</TabsTrigger>
|
<TabsTrigger value="claude" className="gap-2">
|
||||||
|
<AnthropicIcon className="w-3.5 h-3.5" />
|
||||||
|
Claude
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{showCodexTab && (
|
||||||
|
<TabsTrigger value="codex" className="gap-2">
|
||||||
|
<OpenAIIcon className="w-3.5 h-3.5" />
|
||||||
|
Codex
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
|
{showZaiTab && (
|
||||||
|
<TabsTrigger value="zai" className="gap-2">
|
||||||
|
<ZaiIcon className="w-3.5 h-3.5" />
|
||||||
|
z.ai
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -552,6 +628,122 @@ export function UsagePopover() {
|
|||||||
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* z.ai Tab Content */}
|
||||||
|
<TabsContent value="zai" 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">
|
||||||
|
<ZaiIcon className="w-4 h-4" />
|
||||||
|
<span className="text-sm font-semibold">z.ai Usage</span>
|
||||||
|
</div>
|
||||||
|
{zaiError && zaiError.code !== ERROR_CODES.NOT_AVAILABLE && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('h-6 w-6', zaiLoading && 'opacity-80')}
|
||||||
|
onClick={() => !zaiLoading && fetchZaiUsage()}
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 space-y-4">
|
||||||
|
{zaiError ? (
|
||||||
|
<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">
|
||||||
|
{zaiError.code === ERROR_CODES.NOT_AVAILABLE
|
||||||
|
? 'z.ai not configured'
|
||||||
|
: zaiError.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? (
|
||||||
|
'Ensure the Electron bridge is running or restart the app'
|
||||||
|
) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? (
|
||||||
|
<>
|
||||||
|
Set <code className="font-mono bg-muted px-1 rounded">Z_AI_API_KEY</code>{' '}
|
||||||
|
environment variable to enable z.ai usage tracking
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Check your z.ai API key configuration</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !zaiUsage ? (
|
||||||
|
<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>
|
||||||
|
) : zaiUsage.quotaLimits &&
|
||||||
|
(zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? (
|
||||||
|
<>
|
||||||
|
{zaiUsage.quotaLimits.tokens && (
|
||||||
|
<UsageCard
|
||||||
|
title="Token Quota"
|
||||||
|
subtitle={`${formatNumber(zaiUsage.quotaLimits.tokens.used)} / ${formatNumber(zaiUsage.quotaLimits.tokens.limit)} tokens`}
|
||||||
|
percentage={zaiUsage.quotaLimits.tokens.usedPercent}
|
||||||
|
resetText={
|
||||||
|
zaiUsage.quotaLimits.tokens.nextResetTime
|
||||||
|
? formatResetTime(zaiUsage.quotaLimits.tokens.nextResetTime, true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
isPrimary={true}
|
||||||
|
stale={isZaiStale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zaiUsage.quotaLimits.mcp && (
|
||||||
|
<UsageCard
|
||||||
|
title="MCP Quota"
|
||||||
|
subtitle={`${formatNumber(zaiUsage.quotaLimits.mcp.used)} / ${formatNumber(zaiUsage.quotaLimits.mcp.limit)} calls`}
|
||||||
|
percentage={zaiUsage.quotaLimits.mcp.usedPercent}
|
||||||
|
resetText={
|
||||||
|
zaiUsage.quotaLimits.mcp.nextResetTime
|
||||||
|
? formatResetTime(zaiUsage.quotaLimits.mcp.nextResetTime, true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
stale={isZaiStale}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{zaiUsage.quotaLimits.planType && zaiUsage.quotaLimits.planType !== 'unknown' && (
|
||||||
|
<div className="rounded-xl border border-border/40 bg-secondary/20 p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Plan:{' '}
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() +
|
||||||
|
zaiUsage.quotaLimits.planType.slice(1)}
|
||||||
|
</span>
|
||||||
|
</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">No usage data available</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://z.ai"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-[10px] text-muted-foreground hover:text-foreground flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
z.ai <ExternalLink className="w-2.5 h-2.5" />
|
||||||
|
</a>
|
||||||
|
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export function BoardHeader({
|
|||||||
(state) => state.setAddFeatureUseSelectedWorktreeBranch
|
(state) => state.setAddFeatureUseSelectedWorktreeBranch
|
||||||
);
|
);
|
||||||
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus);
|
||||||
|
const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus);
|
||||||
|
|
||||||
// Worktree panel visibility (per-project)
|
// Worktree panel visibility (per-project)
|
||||||
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject);
|
||||||
@@ -112,6 +113,9 @@ export function BoardHeader({
|
|||||||
// Show if Codex is authenticated (CLI or API key)
|
// Show if Codex is authenticated (CLI or API key)
|
||||||
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
const showCodexUsage = !!codexAuthStatus?.authenticated;
|
||||||
|
|
||||||
|
// z.ai usage tracking visibility logic
|
||||||
|
const showZaiUsage = !!zaiAuthStatus?.authenticated;
|
||||||
|
|
||||||
// State for mobile actions panel
|
// State for mobile actions panel
|
||||||
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
const [showActionsPanel, setShowActionsPanel] = useState(false);
|
||||||
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
const [isRefreshingBoard, setIsRefreshingBoard] = useState(false);
|
||||||
@@ -158,8 +162,10 @@ export function BoardHeader({
|
|||||||
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
|
{/* Usage Popover - show if any provider is authenticated, only on desktop */}
|
||||||
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
|
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && (
|
||||||
|
<UsagePopover />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
{/* Tablet/Mobile view: show hamburger menu with all controls */}
|
||||||
{isMounted && isTablet && (
|
{isMounted && isTablet && (
|
||||||
@@ -178,6 +184,7 @@ export function BoardHeader({
|
|||||||
onOpenPlanDialog={onOpenPlanDialog}
|
onOpenPlanDialog={onOpenPlanDialog}
|
||||||
showClaudeUsage={showClaudeUsage}
|
showClaudeUsage={showClaudeUsage}
|
||||||
showCodexUsage={showCodexUsage}
|
showCodexUsage={showCodexUsage}
|
||||||
|
showZaiUsage={showZaiUsage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface HeaderMobileMenuProps {
|
|||||||
// Usage bar visibility
|
// Usage bar visibility
|
||||||
showClaudeUsage: boolean;
|
showClaudeUsage: boolean;
|
||||||
showCodexUsage: boolean;
|
showCodexUsage: boolean;
|
||||||
|
showZaiUsage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeaderMobileMenu({
|
export function HeaderMobileMenu({
|
||||||
@@ -47,18 +48,23 @@ export function HeaderMobileMenu({
|
|||||||
onOpenPlanDialog,
|
onOpenPlanDialog,
|
||||||
showClaudeUsage,
|
showClaudeUsage,
|
||||||
showCodexUsage,
|
showCodexUsage,
|
||||||
|
showZaiUsage = false,
|
||||||
}: HeaderMobileMenuProps) {
|
}: HeaderMobileMenuProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
|
||||||
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
|
||||||
{/* Usage Bar - show if either provider is authenticated */}
|
{/* Usage Bar - show if any provider is authenticated */}
|
||||||
{(showClaudeUsage || showCodexUsage) && (
|
{(showClaudeUsage || showCodexUsage || showZaiUsage) && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
Usage
|
Usage
|
||||||
</span>
|
</span>
|
||||||
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
|
<MobileUsageBar
|
||||||
|
showClaudeUsage={showClaudeUsage}
|
||||||
|
showCodexUsage={showCodexUsage}
|
||||||
|
showZaiUsage={showZaiUsage}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { cn } from '@/lib/utils';
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon';
|
import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon';
|
||||||
|
|
||||||
interface MobileUsageBarProps {
|
interface MobileUsageBarProps {
|
||||||
showClaudeUsage: boolean;
|
showClaudeUsage: boolean;
|
||||||
showCodexUsage: boolean;
|
showCodexUsage: boolean;
|
||||||
|
showZaiUsage?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to get progress bar color based on percentage
|
// Helper to get progress bar color based on percentage
|
||||||
@@ -18,15 +19,51 @@ function getProgressBarColor(percentage: number): string {
|
|||||||
return 'bg-green-500';
|
return 'bg-green-500';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to format large numbers with K/M suffixes
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1_000_000_000) {
|
||||||
|
return `${(num / 1_000_000_000).toFixed(1)}B`;
|
||||||
|
}
|
||||||
|
if (num >= 1_000_000) {
|
||||||
|
return `${(num / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (num >= 1_000) {
|
||||||
|
return `${(num / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to format reset time
|
||||||
|
function formatResetTime(unixTimestamp: number, isMilliseconds = false): string {
|
||||||
|
const date = new Date(isMilliseconds ? unixTimestamp : unixTimestamp * 1000);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = date.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (diff < 3600000) {
|
||||||
|
const mins = Math.ceil(diff / 60000);
|
||||||
|
return `Resets in ${mins}m`;
|
||||||
|
}
|
||||||
|
if (diff < 86400000) {
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const mins = Math.ceil((diff % 3600000) / 60000);
|
||||||
|
return `Resets in ${hours}h${mins > 0 ? ` ${mins}m` : ''}`;
|
||||||
|
}
|
||||||
|
return `Resets ${date.toLocaleDateString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Individual usage bar component
|
// Individual usage bar component
|
||||||
function UsageBar({
|
function UsageBar({
|
||||||
label,
|
label,
|
||||||
percentage,
|
percentage,
|
||||||
isStale,
|
isStale,
|
||||||
|
details,
|
||||||
|
resetText,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
isStale: boolean;
|
isStale: boolean;
|
||||||
|
details?: string;
|
||||||
|
resetText?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="mt-1.5 first:mt-0">
|
<div className="mt-1.5 first:mt-0">
|
||||||
@@ -58,6 +95,14 @@ function UsageBar({
|
|||||||
style={{ width: `${Math.min(percentage, 100)}%` }}
|
style={{ width: `${Math.min(percentage, 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{(details || resetText) && (
|
||||||
|
<div className="flex items-center justify-between mt-0.5">
|
||||||
|
{details && <span className="text-[9px] text-muted-foreground">{details}</span>}
|
||||||
|
{resetText && (
|
||||||
|
<span className="text-[9px] text-muted-foreground ml-auto">{resetText}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -103,16 +148,23 @@ function UsageItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageBarProps) {
|
export function MobileUsageBar({
|
||||||
|
showClaudeUsage,
|
||||||
|
showCodexUsage,
|
||||||
|
showZaiUsage = false,
|
||||||
|
}: MobileUsageBarProps) {
|
||||||
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
const { claudeUsage, claudeUsageLastUpdated, setClaudeUsage } = useAppStore();
|
||||||
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore();
|
||||||
|
const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore();
|
||||||
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
const [isClaudeLoading, setIsClaudeLoading] = useState(false);
|
||||||
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
const [isCodexLoading, setIsCodexLoading] = useState(false);
|
||||||
|
const [isZaiLoading, setIsZaiLoading] = useState(false);
|
||||||
|
|
||||||
// Check if data is stale (older than 2 minutes)
|
// Check if data is stale (older than 2 minutes)
|
||||||
const isClaudeStale =
|
const isClaudeStale =
|
||||||
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
!claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000;
|
||||||
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000;
|
||||||
|
const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000;
|
||||||
|
|
||||||
const fetchClaudeUsage = useCallback(async () => {
|
const fetchClaudeUsage = useCallback(async () => {
|
||||||
setIsClaudeLoading(true);
|
setIsClaudeLoading(true);
|
||||||
@@ -146,6 +198,22 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
}
|
}
|
||||||
}, [setCodexUsage]);
|
}, [setCodexUsage]);
|
||||||
|
|
||||||
|
const fetchZaiUsage = useCallback(async () => {
|
||||||
|
setIsZaiLoading(true);
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
if (!api.zai) return;
|
||||||
|
const data = await api.zai.getUsage();
|
||||||
|
if (!('error' in data)) {
|
||||||
|
setZaiUsage(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently fail - usage display is optional
|
||||||
|
} finally {
|
||||||
|
setIsZaiLoading(false);
|
||||||
|
}
|
||||||
|
}, [setZaiUsage]);
|
||||||
|
|
||||||
const getCodexWindowLabel = (durationMins: number) => {
|
const getCodexWindowLabel = (durationMins: number) => {
|
||||||
if (durationMins < 60) return `${durationMins}m Window`;
|
if (durationMins < 60) return `${durationMins}m Window`;
|
||||||
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
|
if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`;
|
||||||
@@ -165,8 +233,14 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
}
|
}
|
||||||
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
|
}, [showCodexUsage, isCodexStale, fetchCodexUsage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showZaiUsage && isZaiStale) {
|
||||||
|
fetchZaiUsage();
|
||||||
|
}
|
||||||
|
}, [showZaiUsage, isZaiStale, fetchZaiUsage]);
|
||||||
|
|
||||||
// Don't render if there's nothing to show
|
// Don't render if there's nothing to show
|
||||||
if (!showClaudeUsage && !showCodexUsage) {
|
if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +301,45 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB
|
|||||||
)}
|
)}
|
||||||
</UsageItem>
|
</UsageItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showZaiUsage && (
|
||||||
|
<UsageItem icon={ZaiIcon} label="z.ai" isLoading={isZaiLoading} onRefresh={fetchZaiUsage}>
|
||||||
|
{zaiUsage?.quotaLimits && (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? (
|
||||||
|
<>
|
||||||
|
{zaiUsage.quotaLimits.tokens && (
|
||||||
|
<UsageBar
|
||||||
|
label="Tokens"
|
||||||
|
percentage={zaiUsage.quotaLimits.tokens.usedPercent}
|
||||||
|
isStale={isZaiStale}
|
||||||
|
details={`${formatNumber(zaiUsage.quotaLimits.tokens.used)} / ${formatNumber(zaiUsage.quotaLimits.tokens.limit)}`}
|
||||||
|
resetText={
|
||||||
|
zaiUsage.quotaLimits.tokens.nextResetTime
|
||||||
|
? formatResetTime(zaiUsage.quotaLimits.tokens.nextResetTime, true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{zaiUsage.quotaLimits.mcp && (
|
||||||
|
<UsageBar
|
||||||
|
label="MCP"
|
||||||
|
percentage={zaiUsage.quotaLimits.mcp.usedPercent}
|
||||||
|
isStale={isZaiStale}
|
||||||
|
details={`${formatNumber(zaiUsage.quotaLimits.mcp.used)} / ${formatNumber(zaiUsage.quotaLimits.mcp.limit)} calls`}
|
||||||
|
resetText={
|
||||||
|
zaiUsage.quotaLimits.mcp.nextResetTime
|
||||||
|
? formatResetTime(zaiUsage.quotaLimits.mcp.nextResetTime, true)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : zaiUsage ? (
|
||||||
|
<p className="text-[10px] text-muted-foreground italic">No usage data from z.ai API</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-[10px] text-muted-foreground italic">Loading usage data...</p>
|
||||||
|
)}
|
||||||
|
</UsageItem>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
// @ts-nocheck - API key management state with validation and persistence
|
// @ts-nocheck - API key management state with validation and persistence
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
import { useAppStore } from '@/store/app-store';
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { useSetupStore, type ZaiAuthMethod } from '@/store/setup-store';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
|
|
||||||
const logger = createLogger('ApiKeyManagement');
|
const logger = createLogger('ApiKeyManagement');
|
||||||
import { getElectronAPI } from '@/lib/electron';
|
import { getElectronAPI } from '@/lib/electron';
|
||||||
@@ -16,6 +20,7 @@ interface ApiKeyStatus {
|
|||||||
hasAnthropicKey: boolean;
|
hasAnthropicKey: boolean;
|
||||||
hasGoogleKey: boolean;
|
hasGoogleKey: boolean;
|
||||||
hasOpenaiKey: boolean;
|
hasOpenaiKey: boolean;
|
||||||
|
hasZaiKey: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,16 +29,20 @@ interface ApiKeyStatus {
|
|||||||
*/
|
*/
|
||||||
export function useApiKeyManagement() {
|
export function useApiKeyManagement() {
|
||||||
const { apiKeys, setApiKeys } = useAppStore();
|
const { apiKeys, setApiKeys } = useAppStore();
|
||||||
|
const { setZaiAuthStatus } = useSetupStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// API key values
|
// API key values
|
||||||
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic);
|
||||||
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
const [googleKey, setGoogleKey] = useState(apiKeys.google);
|
||||||
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
const [openaiKey, setOpenaiKey] = useState(apiKeys.openai);
|
||||||
|
const [zaiKey, setZaiKey] = useState(apiKeys.zai);
|
||||||
|
|
||||||
// Visibility toggles
|
// Visibility toggles
|
||||||
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
const [showAnthropicKey, setShowAnthropicKey] = useState(false);
|
||||||
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
const [showGoogleKey, setShowGoogleKey] = useState(false);
|
||||||
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
const [showOpenaiKey, setShowOpenaiKey] = useState(false);
|
||||||
|
const [showZaiKey, setShowZaiKey] = useState(false);
|
||||||
|
|
||||||
// Test connection states
|
// Test connection states
|
||||||
const [testingConnection, setTestingConnection] = useState(false);
|
const [testingConnection, setTestingConnection] = useState(false);
|
||||||
@@ -42,6 +51,8 @@ export function useApiKeyManagement() {
|
|||||||
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
|
const [geminiTestResult, setGeminiTestResult] = useState<TestResult | null>(null);
|
||||||
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
|
||||||
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(null);
|
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(null);
|
||||||
|
const [testingZaiConnection, setTestingZaiConnection] = useState(false);
|
||||||
|
const [zaiTestResult, setZaiTestResult] = useState<TestResult | null>(null);
|
||||||
|
|
||||||
// API key status from environment
|
// API key status from environment
|
||||||
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(null);
|
||||||
@@ -54,6 +65,7 @@ export function useApiKeyManagement() {
|
|||||||
setAnthropicKey(apiKeys.anthropic);
|
setAnthropicKey(apiKeys.anthropic);
|
||||||
setGoogleKey(apiKeys.google);
|
setGoogleKey(apiKeys.google);
|
||||||
setOpenaiKey(apiKeys.openai);
|
setOpenaiKey(apiKeys.openai);
|
||||||
|
setZaiKey(apiKeys.zai);
|
||||||
}, [apiKeys]);
|
}, [apiKeys]);
|
||||||
|
|
||||||
// Check API key status from environment on mount
|
// Check API key status from environment on mount
|
||||||
@@ -68,6 +80,7 @@ export function useApiKeyManagement() {
|
|||||||
hasAnthropicKey: status.hasAnthropicKey,
|
hasAnthropicKey: status.hasAnthropicKey,
|
||||||
hasGoogleKey: status.hasGoogleKey,
|
hasGoogleKey: status.hasGoogleKey,
|
||||||
hasOpenaiKey: status.hasOpenaiKey,
|
hasOpenaiKey: status.hasOpenaiKey,
|
||||||
|
hasZaiKey: status.hasZaiKey || false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -173,13 +186,89 @@ export function useApiKeyManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Test z.ai connection
|
||||||
|
const handleTestZaiConnection = async () => {
|
||||||
|
setTestingZaiConnection(true);
|
||||||
|
setZaiTestResult(null);
|
||||||
|
|
||||||
|
// Validate input first
|
||||||
|
if (!zaiKey || zaiKey.trim().length === 0) {
|
||||||
|
setZaiTestResult({
|
||||||
|
success: false,
|
||||||
|
message: 'Please enter an API key to test.',
|
||||||
|
});
|
||||||
|
setTestingZaiConnection(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
// Use the verify endpoint to test the key without storing it
|
||||||
|
const response = await api.zai?.verify(zaiKey);
|
||||||
|
|
||||||
|
if (response?.success && response?.authenticated) {
|
||||||
|
setZaiTestResult({
|
||||||
|
success: true,
|
||||||
|
message: response.message || 'Connection successful! z.ai API responded.',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setZaiTestResult({
|
||||||
|
success: false,
|
||||||
|
message: response?.error || 'Failed to connect to z.ai API.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setZaiTestResult({
|
||||||
|
success: false,
|
||||||
|
message: 'Network error. Please check your connection.',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTestingZaiConnection(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Save API keys
|
// Save API keys
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
setApiKeys({
|
setApiKeys({
|
||||||
anthropic: anthropicKey,
|
anthropic: anthropicKey,
|
||||||
google: googleKey,
|
google: googleKey,
|
||||||
openai: openaiKey,
|
openai: openaiKey,
|
||||||
|
zai: zaiKey,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Configure z.ai service on the server with the new key
|
||||||
|
if (zaiKey && zaiKey.trim().length > 0) {
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.zai.configure(zaiKey.trim());
|
||||||
|
|
||||||
|
if (result.success || result.isAvailable) {
|
||||||
|
// Update z.ai auth status in the store
|
||||||
|
setZaiAuthStatus({
|
||||||
|
authenticated: true,
|
||||||
|
method: 'api_key' as ZaiAuthMethod,
|
||||||
|
hasApiKey: true,
|
||||||
|
hasEnvApiKey: false,
|
||||||
|
});
|
||||||
|
// Invalidate the z.ai usage query so it refetches with the new key
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
||||||
|
logger.info('z.ai API key configured successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to configure z.ai API key:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear z.ai auth status if key is removed
|
||||||
|
setZaiAuthStatus({
|
||||||
|
authenticated: false,
|
||||||
|
method: 'none' as ZaiAuthMethod,
|
||||||
|
hasApiKey: false,
|
||||||
|
hasEnvApiKey: false,
|
||||||
|
});
|
||||||
|
// Invalidate the query to clear any cached data
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.usage.zai() });
|
||||||
|
}
|
||||||
|
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
};
|
};
|
||||||
@@ -214,6 +303,15 @@ export function useApiKeyManagement() {
|
|||||||
onTest: handleTestOpenaiConnection,
|
onTest: handleTestOpenaiConnection,
|
||||||
result: openaiTestResult,
|
result: openaiTestResult,
|
||||||
},
|
},
|
||||||
|
zai: {
|
||||||
|
value: zaiKey,
|
||||||
|
setValue: setZaiKey,
|
||||||
|
show: showZaiKey,
|
||||||
|
setShow: setShowZaiKey,
|
||||||
|
testing: testingZaiConnection,
|
||||||
|
onTest: handleTestZaiConnection,
|
||||||
|
result: zaiTestResult,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Dispatch, SetStateAction } from 'react';
|
import type { Dispatch, SetStateAction } from 'react';
|
||||||
import type { ApiKeys } from '@/store/app-store';
|
import type { ApiKeys } from '@/store/app-store';
|
||||||
|
|
||||||
export type ProviderKey = 'anthropic' | 'google' | 'openai';
|
export type ProviderKey = 'anthropic' | 'google' | 'openai' | 'zai';
|
||||||
|
|
||||||
export interface ProviderConfig {
|
export interface ProviderConfig {
|
||||||
key: ProviderKey;
|
key: ProviderKey;
|
||||||
@@ -59,12 +59,22 @@ export interface ProviderConfigParams {
|
|||||||
onTest: () => Promise<void>;
|
onTest: () => Promise<void>;
|
||||||
result: { success: boolean; message: string } | null;
|
result: { success: boolean; message: string } | null;
|
||||||
};
|
};
|
||||||
|
zai: {
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
show: boolean;
|
||||||
|
setShow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
testing: boolean;
|
||||||
|
onTest: () => Promise<void>;
|
||||||
|
result: { success: boolean; message: string } | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildProviderConfigs = ({
|
export const buildProviderConfigs = ({
|
||||||
apiKeys,
|
apiKeys,
|
||||||
anthropic,
|
anthropic,
|
||||||
openai,
|
openai,
|
||||||
|
zai,
|
||||||
}: ProviderConfigParams): ProviderConfig[] => [
|
}: ProviderConfigParams): ProviderConfig[] => [
|
||||||
{
|
{
|
||||||
key: 'anthropic',
|
key: 'anthropic',
|
||||||
@@ -118,6 +128,32 @@ export const buildProviderConfigs = ({
|
|||||||
descriptionLinkText: 'platform.openai.com',
|
descriptionLinkText: 'platform.openai.com',
|
||||||
descriptionSuffix: '.',
|
descriptionSuffix: '.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'zai',
|
||||||
|
label: 'z.ai API Key',
|
||||||
|
inputId: 'zai-key',
|
||||||
|
placeholder: 'Enter your z.ai API key',
|
||||||
|
value: zai.value,
|
||||||
|
setValue: zai.setValue,
|
||||||
|
showValue: zai.show,
|
||||||
|
setShowValue: zai.setShow,
|
||||||
|
hasStoredKey: apiKeys.zai,
|
||||||
|
inputTestId: 'zai-api-key-input',
|
||||||
|
toggleTestId: 'toggle-zai-visibility',
|
||||||
|
testButton: {
|
||||||
|
onClick: zai.onTest,
|
||||||
|
disabled: !zai.value || zai.testing,
|
||||||
|
loading: zai.testing,
|
||||||
|
testId: 'test-zai-connection',
|
||||||
|
},
|
||||||
|
result: zai.result,
|
||||||
|
resultTestId: 'zai-test-connection-result',
|
||||||
|
resultMessageTestId: 'zai-test-connection-message',
|
||||||
|
descriptionPrefix: 'Used for z.ai usage tracking and GLM models. Get your key at',
|
||||||
|
descriptionLinkHref: 'https://z.ai',
|
||||||
|
descriptionLinkText: 'z.ai',
|
||||||
|
descriptionSuffix: '.',
|
||||||
|
},
|
||||||
// {
|
// {
|
||||||
// key: "google",
|
// key: "google",
|
||||||
// label: "Google API Key (Gemini)",
|
// label: "Google API Key (Gemini)",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export {
|
|||||||
} from './use-github';
|
} from './use-github';
|
||||||
|
|
||||||
// Usage
|
// Usage
|
||||||
export { useClaudeUsage, useCodexUsage } from './use-usage';
|
export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage';
|
||||||
|
|
||||||
// Running Agents
|
// Running Agents
|
||||||
export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';
|
export { useRunningAgents, useRunningAgentsCount } from './use-running-agents';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Usage Query Hooks
|
* Usage Query Hooks
|
||||||
*
|
*
|
||||||
* React Query hooks for fetching Claude and Codex API usage data.
|
* React Query hooks for fetching Claude, Codex, and z.ai API usage data.
|
||||||
* These hooks include automatic polling for real-time usage updates.
|
* 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 { getElectronAPI } from '@/lib/electron';
|
||||||
import { queryKeys } from '@/lib/query-keys';
|
import { queryKeys } from '@/lib/query-keys';
|
||||||
import { STALE_TIMES } from '@/lib/query-client';
|
import { STALE_TIMES } from '@/lib/query-client';
|
||||||
import type { ClaudeUsage, CodexUsage } from '@/store/app-store';
|
import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store';
|
||||||
|
|
||||||
/** Polling interval for usage data (60 seconds) */
|
/** Polling interval for usage data (60 seconds) */
|
||||||
const USAGE_POLLING_INTERVAL = 60 * 1000;
|
const USAGE_POLLING_INTERVAL = 60 * 1000;
|
||||||
@@ -87,3 +87,36 @@ export function useCodexUsage(enabled = true) {
|
|||||||
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
refetchOnReconnect: USAGE_REFETCH_ON_RECONNECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch z.ai API usage data
|
||||||
|
*
|
||||||
|
* @param enabled - Whether the query should run (default: true)
|
||||||
|
* @returns Query result with z.ai usage data
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { data: usage, isLoading } = useZaiUsage(isPopoverOpen);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useZaiUsage(enabled = true) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.usage.zai(),
|
||||||
|
queryFn: async (): Promise<ZaiUsage> => {
|
||||||
|
const api = getElectronAPI();
|
||||||
|
const result = await api.zai.getUsage();
|
||||||
|
// Check if result is an error response
|
||||||
|
if ('error' in result) {
|
||||||
|
throw new Error(result.message || result.error);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
import { useEffect, useRef, useCallback } from 'react';
|
||||||
import { useSetupStore, type ClaudeAuthMethod, type CodexAuthMethod } from '@/store/setup-store';
|
import {
|
||||||
|
useSetupStore,
|
||||||
|
type ClaudeAuthMethod,
|
||||||
|
type CodexAuthMethod,
|
||||||
|
type ZaiAuthMethod,
|
||||||
|
} from '@/store/setup-store';
|
||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { createLogger } from '@automaker/utils/logger';
|
import { createLogger } from '@automaker/utils/logger';
|
||||||
|
|
||||||
const logger = createLogger('ProviderAuthInit');
|
const logger = createLogger('ProviderAuthInit');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook to initialize Claude and Codex authentication statuses on app startup.
|
* Hook to initialize Claude, Codex, and z.ai authentication statuses on app startup.
|
||||||
* This ensures that usage tracking information is available in the board header
|
* This ensures that usage tracking information is available in the board header
|
||||||
* without needing to visit the settings page first.
|
* without needing to visit the settings page first.
|
||||||
*/
|
*/
|
||||||
export function useProviderAuthInit() {
|
export function useProviderAuthInit() {
|
||||||
const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } =
|
const {
|
||||||
useSetupStore();
|
setClaudeAuthStatus,
|
||||||
|
setCodexAuthStatus,
|
||||||
|
setZaiAuthStatus,
|
||||||
|
claudeAuthStatus,
|
||||||
|
codexAuthStatus,
|
||||||
|
zaiAuthStatus,
|
||||||
|
} = useSetupStore();
|
||||||
const initialized = useRef(false);
|
const initialized = useRef(false);
|
||||||
|
|
||||||
const refreshStatuses = useCallback(async () => {
|
const refreshStatuses = useCallback(async () => {
|
||||||
@@ -88,15 +99,40 @@ export function useProviderAuthInit() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to init Codex auth status:', error);
|
logger.error('Failed to init Codex auth status:', error);
|
||||||
}
|
}
|
||||||
}, [setClaudeAuthStatus, setCodexAuthStatus]);
|
|
||||||
|
// 3. z.ai Auth Status
|
||||||
|
try {
|
||||||
|
const result = await api.zai.getStatus();
|
||||||
|
if (result.success || result.available !== undefined) {
|
||||||
|
let method: ZaiAuthMethod = 'none';
|
||||||
|
if (result.hasEnvApiKey) {
|
||||||
|
method = 'api_key_env';
|
||||||
|
} else if (result.hasApiKey || result.available) {
|
||||||
|
method = 'api_key';
|
||||||
|
}
|
||||||
|
|
||||||
|
setZaiAuthStatus({
|
||||||
|
authenticated: result.available,
|
||||||
|
method,
|
||||||
|
hasApiKey: result.hasApiKey ?? result.available,
|
||||||
|
hasEnvApiKey: result.hasEnvApiKey ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to init z.ai auth status:', error);
|
||||||
|
}
|
||||||
|
}, [setClaudeAuthStatus, setCodexAuthStatus, setZaiAuthStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only initialize once per session if not already set
|
// Only initialize once per session if not already set
|
||||||
if (initialized.current || (claudeAuthStatus !== null && codexAuthStatus !== null)) {
|
if (
|
||||||
|
initialized.current ||
|
||||||
|
(claudeAuthStatus !== null && codexAuthStatus !== null && zaiAuthStatus !== null)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
initialized.current = true;
|
initialized.current = true;
|
||||||
|
|
||||||
void refreshStatuses();
|
void refreshStatuses();
|
||||||
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus]);
|
}, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Type definitions for Electron IPC API
|
// Type definitions for Electron IPC API
|
||||||
import type { SessionListItem, Message } from '@/types/electron';
|
import type { SessionListItem, Message } from '@/types/electron';
|
||||||
import type { ClaudeUsageResponse, CodexUsageResponse } from '@/store/app-store';
|
import type { ClaudeUsageResponse, CodexUsageResponse, ZaiUsageResponse } from '@/store/app-store';
|
||||||
import type {
|
import type {
|
||||||
IssueValidationVerdict,
|
IssueValidationVerdict,
|
||||||
IssueValidationConfidence,
|
IssueValidationConfidence,
|
||||||
@@ -865,6 +865,15 @@ export interface ElectronAPI {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
zai?: {
|
||||||
|
getUsage: () => Promise<ZaiUsageResponse>;
|
||||||
|
verify: (apiKey: string) => Promise<{
|
||||||
|
success: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
settings?: {
|
settings?: {
|
||||||
getStatus: () => Promise<{
|
getStatus: () => Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -1364,6 +1373,51 @@ const _getMockElectronAPI = (): ElectronAPI => {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Mock z.ai API
|
||||||
|
zai: {
|
||||||
|
getUsage: async () => {
|
||||||
|
console.log('[Mock] Getting z.ai usage');
|
||||||
|
return {
|
||||||
|
quotaLimits: {
|
||||||
|
tokens: {
|
||||||
|
limitType: 'TOKENS_LIMIT',
|
||||||
|
limit: 1000000,
|
||||||
|
used: 250000,
|
||||||
|
remaining: 750000,
|
||||||
|
usedPercent: 25,
|
||||||
|
nextResetTime: Date.now() + 86400000,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
limitType: 'TIME_LIMIT',
|
||||||
|
limit: 3600,
|
||||||
|
used: 900,
|
||||||
|
remaining: 2700,
|
||||||
|
usedPercent: 25,
|
||||||
|
nextResetTime: Date.now() + 3600000,
|
||||||
|
},
|
||||||
|
planType: 'standard',
|
||||||
|
},
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
verify: async (apiKey: string) => {
|
||||||
|
console.log('[Mock] Verifying z.ai API key');
|
||||||
|
// Mock successful verification if key is provided
|
||||||
|
if (apiKey && apiKey.trim().length > 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
authenticated: true,
|
||||||
|
message: 'Connection successful! z.ai API responded.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
authenticated: false,
|
||||||
|
error: 'Please provide an API key to test.',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1737,6 +1737,67 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// z.ai API
|
||||||
|
zai = {
|
||||||
|
getStatus: (): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
available: boolean;
|
||||||
|
message?: string;
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> => this.get('/api/zai/status'),
|
||||||
|
|
||||||
|
getUsage: (): Promise<{
|
||||||
|
quotaLimits?: {
|
||||||
|
tokens?: {
|
||||||
|
limitType: string;
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
usedPercent: number;
|
||||||
|
nextResetTime: number;
|
||||||
|
};
|
||||||
|
time?: {
|
||||||
|
limitType: string;
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
usedPercent: number;
|
||||||
|
nextResetTime: number;
|
||||||
|
};
|
||||||
|
planType: string;
|
||||||
|
} | null;
|
||||||
|
usageDetails?: Array<{
|
||||||
|
modelId: string;
|
||||||
|
used: number;
|
||||||
|
limit: number;
|
||||||
|
}>;
|
||||||
|
lastUpdated: string;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
}> => this.get('/api/zai/usage'),
|
||||||
|
|
||||||
|
configure: (
|
||||||
|
apiToken?: string,
|
||||||
|
apiHost?: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
isAvailable?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/zai/configure', { apiToken, apiHost }),
|
||||||
|
|
||||||
|
verify: (
|
||||||
|
apiKey: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/zai/verify', { apiKey }),
|
||||||
|
};
|
||||||
|
|
||||||
// Features API
|
// Features API
|
||||||
features: FeaturesAPI & {
|
features: FeaturesAPI & {
|
||||||
bulkUpdate: (
|
bulkUpdate: (
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ export const queryKeys = {
|
|||||||
claude: () => ['usage', 'claude'] as const,
|
claude: () => ['usage', 'claude'] as const,
|
||||||
/** Codex API usage */
|
/** Codex API usage */
|
||||||
codex: () => ['usage', 'codex'] as const,
|
codex: () => ['usage', 'codex'] as const,
|
||||||
|
/** z.ai API usage */
|
||||||
|
zai: () => ['usage', 'zai'] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ import {
|
|||||||
type CodexRateLimitWindow,
|
type CodexRateLimitWindow,
|
||||||
type CodexUsage,
|
type CodexUsage,
|
||||||
type CodexUsageResponse,
|
type CodexUsageResponse,
|
||||||
|
type ZaiPlanType,
|
||||||
|
type ZaiQuotaLimit,
|
||||||
|
type ZaiUsage,
|
||||||
|
type ZaiUsageResponse,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Import utility functions from modular utils files
|
// Import utility functions from modular utils files
|
||||||
@@ -173,6 +177,10 @@ export type {
|
|||||||
CodexRateLimitWindow,
|
CodexRateLimitWindow,
|
||||||
CodexUsage,
|
CodexUsage,
|
||||||
CodexUsageResponse,
|
CodexUsageResponse,
|
||||||
|
ZaiPlanType,
|
||||||
|
ZaiQuotaLimit,
|
||||||
|
ZaiUsage,
|
||||||
|
ZaiUsageResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Re-export values from ./types for backward compatibility
|
// Re-export values from ./types for backward compatibility
|
||||||
@@ -234,6 +242,7 @@ const initialState: AppState = {
|
|||||||
anthropic: '',
|
anthropic: '',
|
||||||
google: '',
|
google: '',
|
||||||
openai: '',
|
openai: '',
|
||||||
|
zai: '',
|
||||||
},
|
},
|
||||||
chatSessions: [],
|
chatSessions: [],
|
||||||
currentChatSession: null,
|
currentChatSession: null,
|
||||||
@@ -314,6 +323,8 @@ const initialState: AppState = {
|
|||||||
claudeUsageLastUpdated: null,
|
claudeUsageLastUpdated: null,
|
||||||
codexUsage: null,
|
codexUsage: null,
|
||||||
codexUsageLastUpdated: null,
|
codexUsageLastUpdated: null,
|
||||||
|
zaiUsage: null,
|
||||||
|
zaiUsageLastUpdated: null,
|
||||||
codexModels: [],
|
codexModels: [],
|
||||||
codexModelsLoading: false,
|
codexModelsLoading: false,
|
||||||
codexModelsError: null,
|
codexModelsError: null,
|
||||||
@@ -2400,6 +2411,9 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
|
|||||||
// Codex Usage Tracking actions
|
// Codex Usage Tracking actions
|
||||||
setCodexUsage: (usage) => set({ codexUsage: usage, codexUsageLastUpdated: Date.now() }),
|
setCodexUsage: (usage) => set({ codexUsage: usage, codexUsageLastUpdated: Date.now() }),
|
||||||
|
|
||||||
|
// z.ai Usage Tracking actions
|
||||||
|
setZaiUsage: (usage) => set({ zaiUsage: usage, zaiUsageLastUpdated: usage ? Date.now() : null }),
|
||||||
|
|
||||||
// Codex Models actions
|
// Codex Models actions
|
||||||
fetchCodexModels: async (forceRefresh = false) => {
|
fetchCodexModels: async (forceRefresh = false) => {
|
||||||
const state = get();
|
const state = get();
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ export interface CodexAuthStatus {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// z.ai Auth Method
|
||||||
|
export type ZaiAuthMethod =
|
||||||
|
| 'api_key_env' // Z_AI_API_KEY environment variable
|
||||||
|
| 'api_key' // Manually stored API key
|
||||||
|
| 'none';
|
||||||
|
|
||||||
|
// z.ai Auth Status
|
||||||
|
export interface ZaiAuthStatus {
|
||||||
|
authenticated: boolean;
|
||||||
|
method: ZaiAuthMethod;
|
||||||
|
hasApiKey?: boolean;
|
||||||
|
hasEnvApiKey?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Claude Auth Method - all possible authentication sources
|
// Claude Auth Method - all possible authentication sources
|
||||||
export type ClaudeAuthMethod =
|
export type ClaudeAuthMethod =
|
||||||
| 'oauth_token_env'
|
| 'oauth_token_env'
|
||||||
@@ -189,6 +204,9 @@ export interface SetupState {
|
|||||||
// Copilot SDK state
|
// Copilot SDK state
|
||||||
copilotCliStatus: CopilotCliStatus | null;
|
copilotCliStatus: CopilotCliStatus | null;
|
||||||
|
|
||||||
|
// z.ai API state
|
||||||
|
zaiAuthStatus: ZaiAuthStatus | null;
|
||||||
|
|
||||||
// Setup preferences
|
// Setup preferences
|
||||||
skipClaudeSetup: boolean;
|
skipClaudeSetup: boolean;
|
||||||
}
|
}
|
||||||
@@ -229,6 +247,9 @@ export interface SetupActions {
|
|||||||
// Copilot SDK
|
// Copilot SDK
|
||||||
setCopilotCliStatus: (status: CopilotCliStatus | null) => void;
|
setCopilotCliStatus: (status: CopilotCliStatus | null) => void;
|
||||||
|
|
||||||
|
// z.ai API
|
||||||
|
setZaiAuthStatus: (status: ZaiAuthStatus | null) => void;
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip: boolean) => void;
|
setSkipClaudeSetup: (skip: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -266,6 +287,8 @@ const initialState: SetupState = {
|
|||||||
|
|
||||||
copilotCliStatus: null,
|
copilotCliStatus: null,
|
||||||
|
|
||||||
|
zaiAuthStatus: null,
|
||||||
|
|
||||||
skipClaudeSetup: shouldSkipSetup,
|
skipClaudeSetup: shouldSkipSetup,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -344,6 +367,9 @@ export const useSetupStore = create<SetupState & SetupActions>()((set, get) => (
|
|||||||
// Copilot SDK
|
// Copilot SDK
|
||||||
setCopilotCliStatus: (status) => set({ copilotCliStatus: status }),
|
setCopilotCliStatus: (status) => set({ copilotCliStatus: status }),
|
||||||
|
|
||||||
|
// z.ai API
|
||||||
|
setZaiAuthStatus: (status) => set({ zaiAuthStatus: status }),
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ export interface ApiKeys {
|
|||||||
anthropic: string;
|
anthropic: string;
|
||||||
google: string;
|
google: string;
|
||||||
openai: string;
|
openai: string;
|
||||||
|
zai: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import type { ApiKeys } from './settings-types';
|
|||||||
import type { ChatMessage, ChatSession, FeatureImage } from './chat-types';
|
import type { ChatMessage, ChatSession, FeatureImage } from './chat-types';
|
||||||
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-types';
|
||||||
import type { Feature, ProjectAnalysis } from './project-types';
|
import type { Feature, ProjectAnalysis } from './project-types';
|
||||||
import type { ClaudeUsage, CodexUsage } from './usage-types';
|
import type { ClaudeUsage, CodexUsage, ZaiUsage } from './usage-types';
|
||||||
|
|
||||||
/** State for worktree init script execution */
|
/** State for worktree init script execution */
|
||||||
export interface InitScriptState {
|
export interface InitScriptState {
|
||||||
@@ -297,6 +297,10 @@ export interface AppState {
|
|||||||
codexUsage: CodexUsage | null;
|
codexUsage: CodexUsage | null;
|
||||||
codexUsageLastUpdated: number | null;
|
codexUsageLastUpdated: number | null;
|
||||||
|
|
||||||
|
// z.ai Usage Tracking
|
||||||
|
zaiUsage: ZaiUsage | null;
|
||||||
|
zaiUsageLastUpdated: number | null;
|
||||||
|
|
||||||
// Codex Models (dynamically fetched)
|
// Codex Models (dynamically fetched)
|
||||||
codexModels: Array<{
|
codexModels: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -764,6 +768,9 @@ export interface AppActions {
|
|||||||
// Codex Usage Tracking actions
|
// Codex Usage Tracking actions
|
||||||
setCodexUsage: (usage: CodexUsage | null) => void;
|
setCodexUsage: (usage: CodexUsage | null) => void;
|
||||||
|
|
||||||
|
// z.ai Usage Tracking actions
|
||||||
|
setZaiUsage: (usage: ZaiUsage | null) => void;
|
||||||
|
|
||||||
// Codex Models actions
|
// Codex Models actions
|
||||||
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
|
fetchCodexModels: (forceRefresh?: boolean) => Promise<void>;
|
||||||
setCodexModels: (
|
setCodexModels: (
|
||||||
|
|||||||
@@ -58,3 +58,27 @@ export interface CodexUsage {
|
|||||||
|
|
||||||
// Response type for Codex usage API (can be success or error)
|
// Response type for Codex usage API (can be success or error)
|
||||||
export type CodexUsageResponse = CodexUsage | { error: string; message?: string };
|
export type CodexUsageResponse = CodexUsage | { error: string; message?: string };
|
||||||
|
|
||||||
|
// z.ai Usage types
|
||||||
|
export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown';
|
||||||
|
|
||||||
|
export interface ZaiQuotaLimit {
|
||||||
|
limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string;
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
remaining: number;
|
||||||
|
usedPercent: number; // Percentage used (0-100)
|
||||||
|
nextResetTime: number; // Epoch milliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZaiUsage {
|
||||||
|
quotaLimits: {
|
||||||
|
tokens?: ZaiQuotaLimit;
|
||||||
|
mcp?: ZaiQuotaLimit;
|
||||||
|
planType: ZaiPlanType;
|
||||||
|
} | null;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response type for z.ai usage API (can be success or error)
|
||||||
|
export type ZaiUsageResponse = ZaiUsage | { error: string; message?: string };
|
||||||
|
|||||||
@@ -1287,6 +1287,8 @@ export interface Credentials {
|
|||||||
google: string;
|
google: string;
|
||||||
/** OpenAI API key (for compatibility or alternative providers) */
|
/** OpenAI API key (for compatibility or alternative providers) */
|
||||||
openai: string;
|
openai: string;
|
||||||
|
/** z.ai API key (for GLM models and usage tracking) */
|
||||||
|
zai: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1615,6 +1617,7 @@ export const DEFAULT_CREDENTIALS: Credentials = {
|
|||||||
anthropic: '',
|
anthropic: '',
|
||||||
google: '',
|
google: '',
|
||||||
openai: '',
|
openai: '',
|
||||||
|
zai: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user