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

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

View File

@@ -0,0 +1,179 @@
import { Router, Request, Response } from 'express';
import { ZaiUsageService } from '../../services/zai-usage-service.js';
import type { SettingsService } from '../../services/settings-service.js';
import { createLogger } from '@automaker/utils';
const logger = createLogger('Zai');
export function createZaiRoutes(
usageService: ZaiUsageService,
settingsService: SettingsService
): Router {
const router = Router();
// Initialize z.ai API token from credentials on startup
(async () => {
try {
const credentials = await settingsService.getCredentials();
if (credentials.apiKeys?.zai) {
usageService.setApiToken(credentials.apiKeys.zai);
logger.info('[init] Loaded z.ai API key from credentials');
}
} catch (error) {
logger.error('[init] Failed to load z.ai API key from credentials:', error);
}
})();
// Get current usage (fetches from z.ai API)
router.get('/usage', async (_req: Request, res: Response) => {
try {
// Check if z.ai API is configured
const isAvailable = usageService.isAvailable();
if (!isAvailable) {
// Use a 200 + error payload so the UI doesn't interpret it as session auth error
res.status(200).json({
error: 'z.ai API not configured',
message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking',
});
return;
}
const usage = await usageService.fetchUsageData();
res.json(usage);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
if (message.includes('not configured') || message.includes('API token')) {
res.status(200).json({
error: 'API token required',
message: 'Set Z_AI_API_KEY environment variable to enable z.ai usage tracking',
});
} else if (message.includes('failed') || message.includes('request')) {
res.status(200).json({
error: 'API request failed',
message: message,
});
} else {
logger.error('Error fetching z.ai usage:', error);
res.status(500).json({ error: message });
}
}
});
// Configure API token (for settings page)
router.post('/configure', async (req: Request, res: Response) => {
try {
const { apiToken, apiHost } = req.body;
if (apiToken !== undefined) {
// Set in-memory token
usageService.setApiToken(apiToken || '');
// Persist to credentials (deep merge happens in updateCredentials)
try {
await settingsService.updateCredentials({
apiKeys: { zai: apiToken || '' },
} as Parameters<typeof settingsService.updateCredentials>[0]);
logger.info('[configure] Saved z.ai API key to credentials');
} catch (persistError) {
logger.error('[configure] Failed to persist z.ai API key:', persistError);
}
}
if (apiHost) {
usageService.setApiHost(apiHost);
}
res.json({
success: true,
message: 'z.ai configuration updated',
isAvailable: usageService.isAvailable(),
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error configuring z.ai:', error);
res.status(500).json({ error: message });
}
});
// Verify API key without storing it (for testing in settings)
router.post('/verify', async (req: Request, res: Response) => {
try {
const { apiKey } = req.body;
if (!apiKey || typeof apiKey !== 'string' || apiKey.trim().length === 0) {
res.json({
success: false,
authenticated: false,
error: 'Please provide an API key to test.',
});
return;
}
// Test the key by making a request to z.ai API
const quotaUrl =
process.env.Z_AI_QUOTA_URL ||
`${process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : 'https://api.z.ai'}/api/monitor/usage/quota/limit`;
logger.info(`[verify] Testing API key against: ${quotaUrl}`);
const response = await fetch(quotaUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey.trim()}`,
Accept: 'application/json',
},
});
if (response.ok) {
res.json({
success: true,
authenticated: true,
message: 'Connection successful! z.ai API responded.',
});
} else if (response.status === 401 || response.status === 403) {
res.json({
success: false,
authenticated: false,
error: 'Invalid API key. Please check your key and try again.',
});
} else {
res.json({
success: false,
authenticated: false,
error: `API request failed: ${response.status} ${response.statusText}`,
});
}
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
logger.error('Error verifying z.ai API key:', error);
res.json({
success: false,
authenticated: false,
error: `Network error: ${message}`,
});
}
});
// Check if z.ai is available
router.get('/status', async (_req: Request, res: Response) => {
try {
const isAvailable = usageService.isAvailable();
const hasEnvApiKey = Boolean(process.env.Z_AI_API_KEY);
const hasApiKey = usageService.getApiToken() !== null;
res.json({
success: true,
available: isAvailable,
hasApiKey,
hasEnvApiKey,
message: isAvailable ? 'z.ai API is configured' : 'z.ai API token not configured',
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
res.status(500).json({ success: false, error: message });
}
});
return router;
}

View File

@@ -1018,6 +1018,7 @@ export class SettingsService {
anthropic: apiKeys.anthropic || '',
google: apiKeys.google || '',
openai: apiKeys.openai || '',
zai: '',
},
});
migratedCredentials = true;

View File

@@ -0,0 +1,375 @@
import { createLogger } from '@automaker/utils';
const logger = createLogger('ZaiUsage');
/**
* z.ai quota limit entry from the API
*/
export interface ZaiQuotaLimit {
limitType: 'TOKENS_LIMIT' | 'TIME_LIMIT' | string;
limit: number;
used: number;
remaining: number;
usedPercent: number;
nextResetTime: number; // epoch milliseconds
}
/**
* z.ai usage details by model (for MCP tracking)
*/
export interface ZaiUsageDetail {
modelId: string;
used: number;
limit: number;
}
/**
* z.ai plan types
*/
export type ZaiPlanType = 'free' | 'basic' | 'standard' | 'professional' | 'enterprise' | 'unknown';
/**
* z.ai usage data structure
*/
export interface ZaiUsageData {
quotaLimits: {
tokens?: ZaiQuotaLimit;
mcp?: ZaiQuotaLimit;
planType: ZaiPlanType;
} | null;
usageDetails?: ZaiUsageDetail[];
lastUpdated: string;
}
/**
* z.ai API limit entry - supports multiple field naming conventions
*/
interface ZaiApiLimit {
// Type field (z.ai uses 'type', others might use 'limitType')
type?: string;
limitType?: string;
// Limit value (z.ai uses 'usage' for total limit, others might use 'limit')
usage?: number;
limit?: number;
// Used value (z.ai uses 'currentValue', others might use 'used')
currentValue?: number;
used?: number;
// Remaining
remaining?: number;
// Percentage (z.ai uses 'percentage', others might use 'usedPercent')
percentage?: number;
usedPercent?: number;
// Reset time
nextResetTime?: number;
// Additional z.ai fields
unit?: number;
number?: number;
usageDetails?: Array<{ modelCode: string; usage: number }>;
}
/**
* z.ai API response structure
* Flexible to handle various possible response formats
*/
interface ZaiApiResponse {
code?: number;
success?: boolean;
data?: {
limits?: ZaiApiLimit[];
// Alternative: limits might be an object instead of array
tokensLimit?: {
limit: number;
used: number;
remaining?: number;
usedPercent?: number;
nextResetTime?: number;
};
timeLimit?: {
limit: number;
used: number;
remaining?: number;
usedPercent?: number;
nextResetTime?: number;
};
// Quota-style fields
quota?: number;
quotaUsed?: number;
quotaRemaining?: number;
planName?: string;
plan?: string;
plan_type?: string;
packageName?: string;
usageDetails?: Array<{
modelId: string;
used: number;
limit: number;
}>;
};
// Root-level alternatives
limits?: ZaiApiLimit[];
quota?: number;
quotaUsed?: number;
message?: string;
}
/**
* z.ai Usage Service
*
* Fetches usage quota data from the z.ai API.
* Uses API token authentication stored via environment variable or settings.
*/
export class ZaiUsageService {
private apiToken: string | null = null;
private apiHost: string = 'https://api.z.ai';
/**
* Set the API token for authentication
*/
setApiToken(token: string): void {
this.apiToken = token;
logger.info('[setApiToken] API token configured');
}
/**
* Get the current API token
*/
getApiToken(): string | null {
// Priority: 1. Instance token, 2. Environment variable
return this.apiToken || process.env.Z_AI_API_KEY || null;
}
/**
* Set the API host (for BigModel CN region support)
*/
setApiHost(host: string): void {
this.apiHost = host.startsWith('http') ? host : `https://${host}`;
logger.info(`[setApiHost] API host set to: ${this.apiHost}`);
}
/**
* Get the API host
*/
getApiHost(): string {
// Priority: 1. Instance host, 2. Z_AI_API_HOST env, 3. Default
return process.env.Z_AI_API_HOST ? `https://${process.env.Z_AI_API_HOST}` : this.apiHost;
}
/**
* Check if z.ai API is available (has token configured)
*/
isAvailable(): boolean {
const token = this.getApiToken();
return Boolean(token && token.length > 0);
}
/**
* Fetch usage data from z.ai API
*/
async fetchUsageData(): Promise<ZaiUsageData> {
logger.info('[fetchUsageData] Starting...');
const token = this.getApiToken();
if (!token) {
logger.error('[fetchUsageData] No API token configured');
throw new Error('z.ai API token not configured. Set Z_AI_API_KEY environment variable.');
}
const quotaUrl =
process.env.Z_AI_QUOTA_URL || `${this.getApiHost()}/api/monitor/usage/quota/limit`;
logger.info(`[fetchUsageData] Fetching from: ${quotaUrl}`);
try {
const response = await fetch(quotaUrl, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
if (!response.ok) {
logger.error(`[fetchUsageData] HTTP ${response.status}: ${response.statusText}`);
throw new Error(`z.ai API request failed: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as unknown as ZaiApiResponse;
logger.info('[fetchUsageData] Response received:', JSON.stringify(data, null, 2));
return this.parseApiResponse(data);
} catch (error) {
if (error instanceof Error && error.message.includes('z.ai API')) {
throw error;
}
logger.error('[fetchUsageData] Failed to fetch:', error);
throw new Error(
`Failed to fetch z.ai usage data: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Parse the z.ai API response into our data structure
* Handles multiple possible response formats from z.ai API
*/
private parseApiResponse(response: ZaiApiResponse): ZaiUsageData {
const result: ZaiUsageData = {
quotaLimits: {
planType: 'unknown',
},
lastUpdated: new Date().toISOString(),
};
logger.info('[parseApiResponse] Raw response:', JSON.stringify(response, null, 2));
// Try to find data - could be in response.data or at root level
let data = response.data;
// Check for root-level limits array
if (!data && response.limits) {
logger.info('[parseApiResponse] Found limits at root level');
data = { limits: response.limits };
}
// Check for root-level quota fields
if (!data && (response.quota !== undefined || response.quotaUsed !== undefined)) {
logger.info('[parseApiResponse] Found quota fields at root level');
data = { quota: response.quota, quotaUsed: response.quotaUsed };
}
if (!data) {
logger.warn('[parseApiResponse] No data found in response');
return result;
}
logger.info('[parseApiResponse] Data keys:', Object.keys(data));
// Parse plan type from various possible field names
const planName = data.planName || data.plan || data.plan_type || data.packageName;
if (planName) {
const normalizedPlan = String(planName).toLowerCase();
if (['free', 'basic', 'standard', 'professional', 'enterprise'].includes(normalizedPlan)) {
result.quotaLimits!.planType = normalizedPlan as ZaiPlanType;
}
logger.info(`[parseApiResponse] Plan type: ${result.quotaLimits!.planType}`);
}
// Parse quota limits from array format
if (data.limits && Array.isArray(data.limits)) {
logger.info('[parseApiResponse] Parsing limits array with', data.limits.length, 'entries');
for (const limit of data.limits) {
logger.info('[parseApiResponse] Processing limit:', JSON.stringify(limit));
// Handle different field naming conventions from z.ai API:
// - 'usage' is the total limit, 'currentValue' is the used amount
// - OR 'limit' is the total limit, 'used' is the used amount
const limitVal = limit.usage ?? limit.limit ?? 0;
const usedVal = limit.currentValue ?? limit.used ?? 0;
// Get percentage from 'percentage' or 'usedPercent' field, or calculate it
const apiPercent = limit.percentage ?? limit.usedPercent;
const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0;
const usedPercent =
apiPercent !== undefined && apiPercent > 0 ? apiPercent : calculatedPercent;
// Get limit type from 'type' or 'limitType' field
const rawLimitType = limit.type ?? limit.limitType ?? '';
const quotaLimit: ZaiQuotaLimit = {
limitType: rawLimitType || 'TOKENS_LIMIT',
limit: limitVal,
used: usedVal,
remaining: limit.remaining ?? limitVal - usedVal,
usedPercent,
nextResetTime: limit.nextResetTime ?? 0,
};
// Match various possible limitType values
const limitType = String(rawLimitType).toUpperCase();
if (limitType.includes('TOKEN') || limitType === 'TOKENS_LIMIT') {
result.quotaLimits!.tokens = quotaLimit;
logger.info(
`[parseApiResponse] Tokens: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)`
);
} else if (limitType.includes('TIME') || limitType === 'TIME_LIMIT') {
result.quotaLimits!.mcp = quotaLimit;
logger.info(
`[parseApiResponse] MCP: ${quotaLimit.used}/${quotaLimit.limit} (${quotaLimit.usedPercent.toFixed(1)}%)`
);
} else {
// If limitType is unknown, use as tokens by default (first one)
if (!result.quotaLimits!.tokens) {
quotaLimit.limitType = 'TOKENS_LIMIT';
result.quotaLimits!.tokens = quotaLimit;
logger.info(`[parseApiResponse] Unknown limit type '${rawLimitType}', using as tokens`);
}
}
}
}
// Parse alternative object-style limits
if (data.tokensLimit) {
const t = data.tokensLimit;
const limitVal = t.limit ?? 0;
const usedVal = t.used ?? 0;
const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0;
result.quotaLimits!.tokens = {
limitType: 'TOKENS_LIMIT',
limit: limitVal,
used: usedVal,
remaining: t.remaining ?? limitVal - usedVal,
usedPercent:
t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent,
nextResetTime: t.nextResetTime ?? 0,
};
logger.info('[parseApiResponse] Parsed tokensLimit object');
}
if (data.timeLimit) {
const t = data.timeLimit;
const limitVal = t.limit ?? 0;
const usedVal = t.used ?? 0;
const calculatedPercent = limitVal > 0 ? (usedVal / limitVal) * 100 : 0;
result.quotaLimits!.mcp = {
limitType: 'TIME_LIMIT',
limit: limitVal,
used: usedVal,
remaining: t.remaining ?? limitVal - usedVal,
usedPercent:
t.usedPercent !== undefined && t.usedPercent > 0 ? t.usedPercent : calculatedPercent,
nextResetTime: t.nextResetTime ?? 0,
};
logger.info('[parseApiResponse] Parsed timeLimit object');
}
// Parse simple quota/quotaUsed format as tokens
if (data.quota !== undefined && data.quotaUsed !== undefined && !result.quotaLimits!.tokens) {
const limitVal = Number(data.quota) || 0;
const usedVal = Number(data.quotaUsed) || 0;
result.quotaLimits!.tokens = {
limitType: 'TOKENS_LIMIT',
limit: limitVal,
used: usedVal,
remaining:
data.quotaRemaining !== undefined ? Number(data.quotaRemaining) : limitVal - usedVal,
usedPercent: limitVal > 0 ? (usedVal / limitVal) * 100 : 0,
nextResetTime: 0,
};
logger.info('[parseApiResponse] Parsed simple quota format');
}
// Parse usage details (MCP tracking)
if (data.usageDetails && Array.isArray(data.usageDetails)) {
result.usageDetails = data.usageDetails.map((detail) => ({
modelId: detail.modelId,
used: detail.used,
limit: detail.limit,
}));
logger.info(`[parseApiResponse] Usage details for ${result.usageDetails.length} models`);
}
logger.info('[parseApiResponse] Final result:', JSON.stringify(result, null, 2));
return result;
}
}