diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 85ff0145..acff315e 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -66,6 +66,8 @@ import { createCodexRoutes } from './routes/codex/index.js'; import { CodexUsageService } from './services/codex-usage-service.js'; import { CodexAppServerService } from './services/codex-app-server-service.js'; import { CodexModelCacheService } from './services/codex-model-cache-service.js'; +import { createZaiRoutes } from './routes/zai/index.js'; +import { ZaiUsageService } from './services/zai-usage-service.js'; import { createGitHubRoutes } from './routes/github/index.js'; import { createContextRoutes } from './routes/context/index.js'; import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js'; @@ -326,6 +328,7 @@ const claudeUsageService = new ClaudeUsageService(); const codexAppServerService = new CodexAppServerService(); const codexModelCacheService = new CodexModelCacheService(DATA_DIR, codexAppServerService); const codexUsageService = new CodexUsageService(codexAppServerService); +const zaiUsageService = new ZaiUsageService(); const mcpTestService = new MCPTestService(settingsService); 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/claude', createClaudeRoutes(claudeUsageService)); app.use('/api/codex', createCodexRoutes(codexUsageService, codexModelCacheService)); +app.use('/api/zai', createZaiRoutes(zaiUsageService, settingsService)); app.use('/api/github', createGitHubRoutes(events, settingsService)); app.use('/api/context', createContextRoutes(settingsService)); app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService)); diff --git a/apps/server/src/routes/zai/index.ts b/apps/server/src/routes/zai/index.ts new file mode 100644 index 00000000..baf84e19 --- /dev/null +++ b/apps/server/src/routes/zai/index.ts @@ -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[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; +} diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 6ffdd488..80e8987f 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -1018,6 +1018,7 @@ export class SettingsService { anthropic: apiKeys.anthropic || '', google: apiKeys.google || '', openai: apiKeys.openai || '', + zai: '', }, }); migratedCredentials = true; diff --git a/apps/server/src/services/zai-usage-service.ts b/apps/server/src/services/zai-usage-service.ts new file mode 100644 index 00000000..c19cf638 --- /dev/null +++ b/apps/server/src/services/zai-usage-service.ts @@ -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 { + 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; + } +} diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx index 415872ce..637fd812 100644 --- a/apps/ui/src/components/ui/provider-icon.tsx +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record }, glm: { 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', + fill: '#3B82F6', // z.ai brand blue }, bigpickle: { viewBox: '0 0 24 24', @@ -391,12 +392,15 @@ export function GlmIcon({ className, title, ...props }: { className?: string; ti {title && {title}} ); } +// Z.ai icon is the same as GLM (Zhipu AI) +export const ZaiIcon = GlmIcon; + export function BigPickleIcon({ className, title, diff --git a/apps/ui/src/components/usage-popover.tsx b/apps/ui/src/components/usage-popover.tsx index 5d8acb0b..31bb6d5a 100644 --- a/apps/ui/src/components/usage-popover.tsx +++ b/apps/ui/src/components/usage-popover.tsx @@ -6,8 +6,8 @@ import { RefreshCw, AlertTriangle, CheckCircle, XCircle, Clock, ExternalLink } f import { Spinner } from '@/components/ui/spinner'; import { cn } from '@/lib/utils'; import { useSetupStore } from '@/store/setup-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; -import { useClaudeUsage, useCodexUsage } from '@/hooks/queries'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; +import { useClaudeUsage, useCodexUsage, useZaiUsage } from '@/hooks/queries'; // Error codes for distinguishing failure modes const ERROR_CODES = { @@ -27,9 +27,9 @@ type UsageError = { const CLAUDE_SESSION_WINDOW_HOURS = 5; -// Helper to format reset time for Codex -function formatCodexResetTime(unixTimestamp: number): string { - const date = new Date(unixTimestamp * 1000); +// Helper to format reset time for Codex/z.ai (unix timestamp in seconds or milliseconds) +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(); @@ -45,6 +45,11 @@ function formatCodexResetTime(unixTimestamp: number): string { 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 function getCodexWindowLabel(durationMins: number): { title: string; subtitle: string } { if (durationMins < 60) { @@ -58,16 +63,32 @@ function getCodexWindowLabel(durationMins: number): { title: string; subtitle: s 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() { const claudeAuthStatus = useSetupStore((state) => state.claudeAuthStatus); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); const [open, setOpen] = useState(false); - const [activeTab, setActiveTab] = useState<'claude' | 'codex'>('claude'); + const [activeTab, setActiveTab] = useState<'claude' | 'codex' | 'zai'>('claude'); // Check authentication status const isClaudeAuthenticated = !!claudeAuthStatus?.authenticated; const isCodexAuthenticated = codexAuthStatus?.authenticated; + const isZaiAuthenticated = zaiAuthStatus?.authenticated; // Use React Query hooks for usage data // Only enable polling when popover is open AND the tab is active @@ -87,6 +108,14 @@ export function UsagePopover() { refetch: refetchCodex, } = 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 const claudeError = useMemo((): UsageError | null => { if (!claudeQueryError) return null; @@ -116,14 +145,28 @@ export function UsagePopover() { return { code: ERROR_CODES.AUTH_ERROR, message }; }, [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 useEffect(() => { if (isClaudeAuthenticated) { setActiveTab('claude'); } else if (isCodexAuthenticated) { setActiveTab('codex'); + } else if (isZaiAuthenticated) { + setActiveTab('zai'); } - }, [isClaudeAuthenticated, isCodexAuthenticated]); + }, [isClaudeAuthenticated, isCodexAuthenticated, isZaiAuthenticated]); // Check if data is stale (older than 2 minutes) const isClaudeStale = useMemo(() => { @@ -134,9 +177,14 @@ export function UsagePopover() { return !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; }, [codexUsageLastUpdated]); + const isZaiStale = useMemo(() => { + return !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; + }, [zaiUsageLastUpdated]); + // Refetch functions for manual refresh const fetchClaudeUsage = () => refetchClaude(); const fetchCodexUsage = () => refetchCodex(); + const fetchZaiUsage = () => refetchZai(); // Derived status color/icon helper const getStatusInfo = (percentage: number) => { @@ -251,26 +299,33 @@ export function UsagePopover() { const indicatorInfo = activeTab === 'claude' ? { - icon: AnthropicIcon, - percentage: claudeSessionPercentage, - isStale: isClaudeStale, - title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, - } - : { - icon: OpenAIIcon, - percentage: codexWindowUsage ?? 0, - isStale: isCodexStale, - title: `Usage (${codexWindowLabel})`, - }; + icon: AnthropicIcon, + percentage: claudeSessionPercentage, + isStale: isClaudeStale, + title: `Session usage (${CLAUDE_SESSION_WINDOW_HOURS}h window)`, + } + : activeTab === 'codex' ? { + icon: OpenAIIcon, + percentage: codexWindowUsage ?? 0, + isStale: isCodexStale, + title: `Usage (${codexWindowLabel})`, + } : activeTab === 'zai' ? { + icon: ZaiIcon, + percentage: zaiMaxPercentage, + isStale: isZaiStale, + title: `Usage (z.ai)`, + } : null; const statusColor = getStatusInfo(indicatorInfo.percentage).color; const ProviderIcon = indicatorInfo.icon; const trigger = ( + )} + + + {/* Content */} +
+ {zaiError ? ( +
+ +
+

+ {zaiError.code === ERROR_CODES.NOT_AVAILABLE + ? 'z.ai not configured' + : zaiError.message} +

+

+ {zaiError.code === ERROR_CODES.API_BRIDGE_UNAVAILABLE ? ( + 'Ensure the Electron bridge is running or restart the app' + ) : zaiError.code === ERROR_CODES.NOT_AVAILABLE ? ( + <> + Set Z_AI_API_KEY{' '} + environment variable to enable z.ai usage tracking + + ) : ( + <>Check your z.ai API key configuration + )} +

+
+
+ ) : !zaiUsage ? ( +
+ +

Loading usage data...

+
+ ) : zaiUsage.quotaLimits && + (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + + {zaiUsage.quotaLimits.mcp && ( + + )} + + {zaiUsage.quotaLimits.planType && zaiUsage.quotaLimits.planType !== 'unknown' && ( +
+

+ Plan:{' '} + + {zaiUsage.quotaLimits.planType.charAt(0).toUpperCase() + + zaiUsage.quotaLimits.planType.slice(1)} + +

+
+ )} + + ) : ( +
+ +

No usage data available

+
+ )} +
+ + {/* Footer */} +
+ + z.ai + + Updates every minute +
+ diff --git a/apps/ui/src/components/views/board-view/board-header.tsx b/apps/ui/src/components/views/board-view/board-header.tsx index 0db3dd48..05303b85 100644 --- a/apps/ui/src/components/views/board-view/board-header.tsx +++ b/apps/ui/src/components/views/board-view/board-header.tsx @@ -81,6 +81,7 @@ export function BoardHeader({ (state) => state.setAddFeatureUseSelectedWorktreeBranch ); const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const zaiAuthStatus = useSetupStore((state) => state.zaiAuthStatus); // Worktree panel visibility (per-project) const worktreePanelVisibleByProject = useAppStore((state) => state.worktreePanelVisibleByProject); @@ -112,6 +113,9 @@ export function BoardHeader({ // Show if Codex is authenticated (CLI or API key) const showCodexUsage = !!codexAuthStatus?.authenticated; + // z.ai usage tracking visibility logic + const showZaiUsage = !!zaiAuthStatus?.authenticated; + // State for mobile actions panel const [showActionsPanel, setShowActionsPanel] = useState(false); const [isRefreshingBoard, setIsRefreshingBoard] = useState(false); @@ -158,8 +162,10 @@ export function BoardHeader({ Refresh board state from server )} - {/* Usage Popover - show if either provider is authenticated, only on desktop */} - {isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && } + {/* Usage Popover - show if any provider is authenticated, only on desktop */} + {isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && ( + + )} {/* Tablet/Mobile view: show hamburger menu with all controls */} {isMounted && isTablet && ( @@ -178,6 +184,7 @@ export function BoardHeader({ onOpenPlanDialog={onOpenPlanDialog} showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} + showZaiUsage={showZaiUsage} /> )} diff --git a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx index f3c2c19d..184e436a 100644 --- a/apps/ui/src/components/views/board-view/header-mobile-menu.tsx +++ b/apps/ui/src/components/views/board-view/header-mobile-menu.tsx @@ -30,6 +30,7 @@ interface HeaderMobileMenuProps { // Usage bar visibility showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } export function HeaderMobileMenu({ @@ -47,18 +48,23 @@ export function HeaderMobileMenu({ onOpenPlanDialog, showClaudeUsage, showCodexUsage, + showZaiUsage = false, }: HeaderMobileMenuProps) { return ( <> - {/* Usage Bar - show if either provider is authenticated */} - {(showClaudeUsage || showCodexUsage) && ( + {/* Usage Bar - show if any provider is authenticated */} + {(showClaudeUsage || showCodexUsage || showZaiUsage) && (
Usage - +
)} diff --git a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx index 918988e9..28225b50 100644 --- a/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx +++ b/apps/ui/src/components/views/board-view/mobile-usage-bar.tsx @@ -4,11 +4,12 @@ import { cn } from '@/lib/utils'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; import { useAppStore } from '@/store/app-store'; -import { AnthropicIcon, OpenAIIcon } from '@/components/ui/provider-icon'; +import { AnthropicIcon, OpenAIIcon, ZaiIcon } from '@/components/ui/provider-icon'; interface MobileUsageBarProps { showClaudeUsage: boolean; showCodexUsage: boolean; + showZaiUsage?: boolean; } // Helper to get progress bar color based on percentage @@ -18,15 +19,51 @@ function getProgressBarColor(percentage: number): string { 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 function UsageBar({ label, percentage, isStale, + details, + resetText, }: { label: string; percentage: number; isStale: boolean; + details?: string; + resetText?: string; }) { return (
@@ -58,6 +95,14 @@ function UsageBar({ style={{ width: `${Math.min(percentage, 100)}%` }} />
+ {(details || resetText) && ( +
+ {details && {details}} + {resetText && ( + {resetText} + )} +
+ )} ); } @@ -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 { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const { zaiUsage, zaiUsageLastUpdated, setZaiUsage } = useAppStore(); const [isClaudeLoading, setIsClaudeLoading] = useState(false); const [isCodexLoading, setIsCodexLoading] = useState(false); + const [isZaiLoading, setIsZaiLoading] = useState(false); // Check if data is stale (older than 2 minutes) const isClaudeStale = !claudeUsageLastUpdated || Date.now() - claudeUsageLastUpdated > 2 * 60 * 1000; const isCodexStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > 2 * 60 * 1000; + const isZaiStale = !zaiUsageLastUpdated || Date.now() - zaiUsageLastUpdated > 2 * 60 * 1000; const fetchClaudeUsage = useCallback(async () => { setIsClaudeLoading(true); @@ -146,6 +198,22 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [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) => { if (durationMins < 60) return `${durationMins}m Window`; if (durationMins < 1440) return `${Math.round(durationMins / 60)}h Window`; @@ -165,8 +233,14 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB } }, [showCodexUsage, isCodexStale, fetchCodexUsage]); + useEffect(() => { + if (showZaiUsage && isZaiStale) { + fetchZaiUsage(); + } + }, [showZaiUsage, isZaiStale, fetchZaiUsage]); + // Don't render if there's nothing to show - if (!showClaudeUsage && !showCodexUsage) { + if (!showClaudeUsage && !showCodexUsage && !showZaiUsage) { return null; } @@ -227,6 +301,45 @@ export function MobileUsageBar({ showClaudeUsage, showCodexUsage }: MobileUsageB )} )} + + {showZaiUsage && ( + + {zaiUsage?.quotaLimits && (zaiUsage.quotaLimits.tokens || zaiUsage.quotaLimits.mcp) ? ( + <> + {zaiUsage.quotaLimits.tokens && ( + + )} + {zaiUsage.quotaLimits.mcp && ( + + )} + + ) : zaiUsage ? ( +

No usage data from z.ai API

+ ) : ( +

Loading usage data...

+ )} +
+ )} ); } diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index 0290ec9e..1b6738ec 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -1,7 +1,11 @@ // @ts-nocheck - API key management state with validation and persistence import { useState, useEffect } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { createLogger } from '@automaker/utils/logger'; 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'); import { getElectronAPI } from '@/lib/electron'; @@ -16,6 +20,7 @@ interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; hasOpenaiKey: boolean; + hasZaiKey: boolean; } /** @@ -24,16 +29,20 @@ interface ApiKeyStatus { */ export function useApiKeyManagement() { const { apiKeys, setApiKeys } = useAppStore(); + const { setZaiAuthStatus } = useSetupStore(); + const queryClient = useQueryClient(); // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); + const [zaiKey, setZaiKey] = useState(apiKeys.zai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); const [showOpenaiKey, setShowOpenaiKey] = useState(false); + const [showZaiKey, setShowZaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); @@ -42,6 +51,8 @@ export function useApiKeyManagement() { const [geminiTestResult, setGeminiTestResult] = useState(null); const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); const [openaiTestResult, setOpenaiTestResult] = useState(null); + const [testingZaiConnection, setTestingZaiConnection] = useState(false); + const [zaiTestResult, setZaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -54,6 +65,7 @@ export function useApiKeyManagement() { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); setOpenaiKey(apiKeys.openai); + setZaiKey(apiKeys.zai); }, [apiKeys]); // Check API key status from environment on mount @@ -68,6 +80,7 @@ export function useApiKeyManagement() { hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, hasOpenaiKey: status.hasOpenaiKey, + hasZaiKey: status.hasZaiKey || false, }); } } 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 - const handleSave = () => { + const handleSave = async () => { setApiKeys({ anthropic: anthropicKey, google: googleKey, 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); setTimeout(() => setSaved(false), 2000); }; @@ -214,6 +303,15 @@ export function useApiKeyManagement() { onTest: handleTestOpenaiConnection, result: openaiTestResult, }, + zai: { + value: zaiKey, + setValue: setZaiKey, + show: showZaiKey, + setShow: setShowZaiKey, + testing: testingZaiConnection, + onTest: handleTestZaiConnection, + result: zaiTestResult, + }, }; return { diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index e3cc2a51..140d0c24 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -1,7 +1,7 @@ import type { Dispatch, SetStateAction } from 'react'; import type { ApiKeys } from '@/store/app-store'; -export type ProviderKey = 'anthropic' | 'google' | 'openai'; +export type ProviderKey = 'anthropic' | 'google' | 'openai' | 'zai'; export interface ProviderConfig { key: ProviderKey; @@ -59,12 +59,22 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + zai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, openai, + zai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -118,6 +128,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'platform.openai.com', 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", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/hooks/queries/index.ts b/apps/ui/src/hooks/queries/index.ts index 8cfdf745..186b5b4e 100644 --- a/apps/ui/src/hooks/queries/index.ts +++ b/apps/ui/src/hooks/queries/index.ts @@ -23,7 +23,7 @@ export { } from './use-github'; // Usage -export { useClaudeUsage, useCodexUsage } from './use-usage'; +export { useClaudeUsage, useCodexUsage, useZaiUsage } from './use-usage'; // Running Agents export { useRunningAgents, useRunningAgentsCount } from './use-running-agents'; diff --git a/apps/ui/src/hooks/queries/use-usage.ts b/apps/ui/src/hooks/queries/use-usage.ts index 523c53f1..c159ac06 100644 --- a/apps/ui/src/hooks/queries/use-usage.ts +++ b/apps/ui/src/hooks/queries/use-usage.ts @@ -1,7 +1,7 @@ /** * Usage Query Hooks * - * React Query hooks for fetching Claude and Codex API usage data. + * React Query hooks for fetching Claude, Codex, and z.ai API usage data. * These hooks include automatic polling for real-time usage updates. */ @@ -9,7 +9,7 @@ import { useQuery } from '@tanstack/react-query'; import { getElectronAPI } from '@/lib/electron'; import { queryKeys } from '@/lib/query-keys'; import { STALE_TIMES } from '@/lib/query-client'; -import type { ClaudeUsage, CodexUsage } from '@/store/app-store'; +import type { ClaudeUsage, CodexUsage, ZaiUsage } from '@/store/app-store'; /** Polling interval for usage data (60 seconds) */ const USAGE_POLLING_INTERVAL = 60 * 1000; @@ -87,3 +87,36 @@ export function useCodexUsage(enabled = true) { 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 => { + 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, + }); +} diff --git a/apps/ui/src/hooks/use-provider-auth-init.ts b/apps/ui/src/hooks/use-provider-auth-init.ts index ae95d121..c784e7bd 100644 --- a/apps/ui/src/hooks/use-provider-auth-init.ts +++ b/apps/ui/src/hooks/use-provider-auth-init.ts @@ -1,18 +1,29 @@ 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 { createLogger } from '@automaker/utils/logger'; 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 * without needing to visit the settings page first. */ export function useProviderAuthInit() { - const { setClaudeAuthStatus, setCodexAuthStatus, claudeAuthStatus, codexAuthStatus } = - useSetupStore(); + const { + setClaudeAuthStatus, + setCodexAuthStatus, + setZaiAuthStatus, + claudeAuthStatus, + codexAuthStatus, + zaiAuthStatus, + } = useSetupStore(); const initialized = useRef(false); const refreshStatuses = useCallback(async () => { @@ -88,15 +99,40 @@ export function useProviderAuthInit() { } catch (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(() => { // 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; } initialized.current = true; void refreshStatuses(); - }, [refreshStatuses, claudeAuthStatus, codexAuthStatus]); + }, [refreshStatuses, claudeAuthStatus, codexAuthStatus, zaiAuthStatus]); } diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index 22079822..14568453 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -1,6 +1,6 @@ // Type definitions for Electron IPC API 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 { IssueValidationVerdict, IssueValidationConfidence, @@ -865,6 +865,15 @@ export interface ElectronAPI { error?: string; }>; }; + zai?: { + getUsage: () => Promise; + verify: (apiKey: string) => Promise<{ + success: boolean; + authenticated: boolean; + message?: string; + error?: string; + }>; + }; settings?: { getStatus: () => Promise<{ 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.', + }; + }, + }, }; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index acd75d22..b65ab872 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -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: FeaturesAPI & { bulkUpdate: ( diff --git a/apps/ui/src/lib/query-keys.ts b/apps/ui/src/lib/query-keys.ts index afe4b5b0..aad0208d 100644 --- a/apps/ui/src/lib/query-keys.ts +++ b/apps/ui/src/lib/query-keys.ts @@ -99,6 +99,8 @@ export const queryKeys = { claude: () => ['usage', 'claude'] as const, /** Codex API usage */ codex: () => ['usage', 'codex'] as const, + /** z.ai API usage */ + zai: () => ['usage', 'zai'] as const, }, // ============================================ diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index c0735355..4d4868b6 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -94,6 +94,10 @@ import { type CodexRateLimitWindow, type CodexUsage, type CodexUsageResponse, + type ZaiPlanType, + type ZaiQuotaLimit, + type ZaiUsage, + type ZaiUsageResponse, } from './types'; // Import utility functions from modular utils files @@ -173,6 +177,10 @@ export type { CodexRateLimitWindow, CodexUsage, CodexUsageResponse, + ZaiPlanType, + ZaiQuotaLimit, + ZaiUsage, + ZaiUsageResponse, }; // Re-export values from ./types for backward compatibility @@ -234,6 +242,7 @@ const initialState: AppState = { anthropic: '', google: '', openai: '', + zai: '', }, chatSessions: [], currentChatSession: null, @@ -314,6 +323,8 @@ const initialState: AppState = { claudeUsageLastUpdated: null, codexUsage: null, codexUsageLastUpdated: null, + zaiUsage: null, + zaiUsageLastUpdated: null, codexModels: [], codexModelsLoading: false, codexModelsError: null, @@ -2400,6 +2411,9 @@ export const useAppStore = create()((set, get) => ({ // Codex Usage Tracking actions 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 fetchCodexModels: async (forceRefresh = false) => { const state = get(); diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index f354e5b1..27a9bdac 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -112,6 +112,21 @@ export interface CodexAuthStatus { 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 export type ClaudeAuthMethod = | 'oauth_token_env' @@ -189,6 +204,9 @@ export interface SetupState { // Copilot SDK state copilotCliStatus: CopilotCliStatus | null; + // z.ai API state + zaiAuthStatus: ZaiAuthStatus | null; + // Setup preferences skipClaudeSetup: boolean; } @@ -229,6 +247,9 @@ export interface SetupActions { // Copilot SDK setCopilotCliStatus: (status: CopilotCliStatus | null) => void; + // z.ai API + setZaiAuthStatus: (status: ZaiAuthStatus | null) => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -266,6 +287,8 @@ const initialState: SetupState = { copilotCliStatus: null, + zaiAuthStatus: null, + skipClaudeSetup: shouldSkipSetup, }; @@ -344,6 +367,9 @@ export const useSetupStore = create()((set, get) => ( // Copilot SDK setCopilotCliStatus: (status) => set({ copilotCliStatus: status }), + // z.ai API + setZaiAuthStatus: (status) => set({ zaiAuthStatus: status }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), })); diff --git a/apps/ui/src/store/types/settings-types.ts b/apps/ui/src/store/types/settings-types.ts index 6adb8097..bf371fd0 100644 --- a/apps/ui/src/store/types/settings-types.ts +++ b/apps/ui/src/store/types/settings-types.ts @@ -2,4 +2,5 @@ export interface ApiKeys { anthropic: string; google: string; openai: string; + zai: string; } diff --git a/apps/ui/src/store/types/state-types.ts b/apps/ui/src/store/types/state-types.ts index 4febb1ca..7bf01968 100644 --- a/apps/ui/src/store/types/state-types.ts +++ b/apps/ui/src/store/types/state-types.ts @@ -36,7 +36,7 @@ import type { ApiKeys } from './settings-types'; import type { ChatMessage, ChatSession, FeatureImage } from './chat-types'; import type { TerminalState, TerminalPanelContent, PersistedTerminalState } from './terminal-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 */ export interface InitScriptState { @@ -297,6 +297,10 @@ export interface AppState { codexUsage: CodexUsage | null; codexUsageLastUpdated: number | null; + // z.ai Usage Tracking + zaiUsage: ZaiUsage | null; + zaiUsageLastUpdated: number | null; + // Codex Models (dynamically fetched) codexModels: Array<{ id: string; @@ -764,6 +768,9 @@ export interface AppActions { // Codex Usage Tracking actions setCodexUsage: (usage: CodexUsage | null) => void; + // z.ai Usage Tracking actions + setZaiUsage: (usage: ZaiUsage | null) => void; + // Codex Models actions fetchCodexModels: (forceRefresh?: boolean) => Promise; setCodexModels: ( diff --git a/apps/ui/src/store/types/usage-types.ts b/apps/ui/src/store/types/usage-types.ts index e097526c..e7c47a5d 100644 --- a/apps/ui/src/store/types/usage-types.ts +++ b/apps/ui/src/store/types/usage-types.ts @@ -58,3 +58,27 @@ export interface CodexUsage { // Response type for Codex usage API (can be success or error) 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 }; diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 06743faa..a71cde89 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -1287,6 +1287,8 @@ export interface Credentials { google: string; /** OpenAI API key (for compatibility or alternative providers) */ openai: string; + /** z.ai API key (for GLM models and usage tracking) */ + zai: string; }; } @@ -1615,6 +1617,7 @@ export const DEFAULT_CREDENTIALS: Credentials = { anthropic: '', google: '', openai: '', + zai: '', }, };