Feat: Add z.ai usage tracking

This commit is contained in:
eclipxe
2026-01-20 14:34:15 -08:00
committed by gsxdsm
parent 1662c6bf0b
commit 7765a12868
23 changed files with 1331 additions and 55 deletions

View File

@@ -105,8 +105,9 @@ const PROVIDER_ICON_DEFINITIONS: Record<ProviderIconKey, ProviderIconDefinition>
},
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>{title}</title>}
<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"
fill="currentColor"
fill="#3B82F6"
/>
</svg>
);
}
// Z.ai icon is the same as GLM (Zhipu AI)
export const ZaiIcon = GlmIcon;
export function BigPickleIcon({
className,
title,

View File

@@ -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 = (
<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>
{(claudeUsage || codexUsage) && (
{(claudeUsage || codexUsage || zaiUsage) && (
<div
title={indicatorInfo.title}
className={cn(
@@ -293,6 +348,8 @@ export function UsagePopover() {
// Determine which tabs to show
const showClaudeTab = isClaudeAuthenticated;
const showCodexTab = isCodexAuthenticated;
const showZaiTab = isZaiAuthenticated;
const tabCount = [showClaudeTab, showCodexTab, showZaiTab].filter(Boolean).length;
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -302,18 +359,37 @@ export function UsagePopover() {
align="end"
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 */}
{showClaudeTab && showCodexTab && (
<TabsList className="grid w-full grid-cols-2 rounded-none border-b border-border/50">
<TabsTrigger value="claude" className="gap-2">
<AnthropicIcon className="w-3.5 h-3.5" />
Claude
</TabsTrigger>
<TabsTrigger value="codex" className="gap-2">
<OpenAIIcon className="w-3.5 h-3.5" />
Codex
</TabsTrigger>
{tabCount > 1 && (
<TabsList
className={cn(
'grid w-full rounded-none border-b border-border/50',
tabCount === 2 && 'grid-cols-2',
tabCount === 3 && 'grid-cols-3'
)}
>
{showClaudeTab && (
<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>
)}
@@ -552,6 +628,122 @@ export function UsagePopover() {
<span className="text-[10px] text-muted-foreground">Updates every minute</span>
</div>
</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>
</PopoverContent>
</Popover>

View File

@@ -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({
<TooltipContent side="bottom">Refresh board state from server</TooltipContent>
</Tooltip>
)}
{/* Usage Popover - show if either provider is authenticated, only on desktop */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage) && <UsagePopover />}
{/* Usage Popover - show if any provider is authenticated, only on desktop */}
{isMounted && !isTablet && (showClaudeUsage || showCodexUsage || showZaiUsage) && (
<UsagePopover />
)}
{/* 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}
/>
)}

View File

@@ -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 (
<>
<HeaderActionsPanelTrigger isOpen={isOpen} onToggle={onToggle} />
<HeaderActionsPanel isOpen={isOpen} onClose={onToggle} title="Board Controls">
{/* Usage Bar - show if either provider is authenticated */}
{(showClaudeUsage || showCodexUsage) && (
{/* Usage Bar - show if any provider is authenticated */}
{(showClaudeUsage || showCodexUsage || showZaiUsage) && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Usage
</span>
<MobileUsageBar showClaudeUsage={showClaudeUsage} showCodexUsage={showCodexUsage} />
<MobileUsageBar
showClaudeUsage={showClaudeUsage}
showCodexUsage={showCodexUsage}
showZaiUsage={showZaiUsage}
/>
</div>
)}

View File

@@ -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 (
<div className="mt-1.5 first:mt-0">
@@ -58,6 +95,14 @@ function UsageBar({
style={{ width: `${Math.min(percentage, 100)}%` }}
/>
</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>
);
}
@@ -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
)}
</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>
);
}

View File

@@ -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<TestResult | null>(null);
const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false);
const [openaiTestResult, setOpenaiTestResult] = useState<TestResult | null>(null);
const [testingZaiConnection, setTestingZaiConnection] = useState(false);
const [zaiTestResult, setZaiTestResult] = useState<TestResult | null>(null);
// API key status from environment
const [apiKeyStatus, setApiKeyStatus] = useState<ApiKeyStatus | null>(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 {

View File

@@ -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<void>;
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 = ({
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)",

View File

@@ -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';

View File

@@ -1,7 +1,7 @@
/**
* Usage Query Hooks
*
* React Query hooks for fetching Claude and Codex API usage data.
* React Query hooks for fetching Claude, Codex, and 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<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,
});
}

View File

@@ -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]);
}

View File

@@ -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<ZaiUsageResponse>;
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.',
};
},
},
};
};

View File

@@ -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: (

View File

@@ -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,
},
// ============================================

View File

@@ -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<AppState & AppActions>()((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();

View File

@@ -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<SetupState & SetupActions>()((set, get) => (
// Copilot SDK
setCopilotCliStatus: (status) => set({ copilotCliStatus: status }),
// z.ai API
setZaiAuthStatus: (status) => set({ zaiAuthStatus: status }),
// Preferences
setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }),
}));

View File

@@ -2,4 +2,5 @@ export interface ApiKeys {
anthropic: string;
google: string;
openai: string;
zai: string;
}

View File

@@ -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<void>;
setCodexModels: (

View File

@@ -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 };