/** * OMC HUD - Usage API * * Fetches rate limit usage from Anthropic's OAuth API, with overrides for * third-party providers (z.ai, MiniMax, Kimi) detected via ANTHROPIC_BASE_URL. * Based on claude-hud implementation by jarrodwatts. * * Authentication: * - macOS: Reads from Keychain "Claude Code-credentials" * - Linux/fallback: Reads from ~/.claude/.credentials.json * * API: api.anthropic.com/api/oauth/usage * Response: { five_hour: { utilization }, seven_day: { utilization } } */ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync } from 'fs'; import { getClaudeConfigDir } from '../utils/config-dir.js'; import { join, dirname } from 'path'; import { execFileSync } from 'child_process'; import { createHash } from 'crypto'; import { userInfo } from 'os'; import https from 'https'; import { validateAnthropicBaseUrl } from '../utils/ssrf-guard.js'; import { DEFAULT_HUD_USAGE_POLL_INTERVAL_MS, type RateLimits, type UsageResult, type UsageErrorReason, } from './types.js'; import { readHudConfig } from './state.js'; import { lockPathFor, withFileLock, type FileLockOptions } from '../lib/file-lock.js'; /** * Usage data providers supported by the built-in usage monitor. * - anthropic: Claude Code OAuth subscription (api.anthropic.com/api/oauth/usage) * - zai: z.ai GLM coding plan (via ANTHROPIC_BASE_URL host detection) * - minimax: MiniMax coding plan (via ANTHROPIC_BASE_URL host detection) * - kimi: Kimi For Coding plan, api.kimi.com (ANTHROPIC_API_KEY per Kimi's * Claude Code docs; KIMI_API_KEY / ANTHROPIC_AUTH_TOKEN also accepted) */ type UsageSource = 'anthropic' | 'zai' | 'minimax' | 'kimi'; // Cache configuration const CACHE_TTL_FAILURE_MS = 15 * 1000; // 15 seconds for non-transient failures const CACHE_TTL_TRANSIENT_NETWORK_MS = 2 * 60 * 1000; // 2 minutes to avoid hammering transient API failures const MAX_RATE_LIMITED_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes max for sustained 429s const API_TIMEOUT_MS = 10000; const MAX_STALE_DATA_MS = 15 * 60 * 1000; // 15 minutes — discard stale data after this const TOKEN_REFRESH_URL_HOSTNAME = 'platform.claude.com'; const USAGE_CACHE_LOCK_OPTS: FileLockOptions = { staleLockMs: API_TIMEOUT_MS + 5000 }; const TOKEN_REFRESH_URL_PATH = '/v1/oauth/token'; /** * OAuth client_id for Claude Code (public client). * This is the production value; can be overridden via CLAUDE_CODE_OAUTH_CLIENT_ID env var. */ const DEFAULT_OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e'; interface UsageCache { timestamp: number; data: RateLimits | null; error?: boolean; /** Preserved error reason for accurate cache-hit reporting */ errorReason?: UsageErrorReason; /** Provider that produced this cache entry */ source?: UsageSource; /** Whether this cache entry was caused by a 429 rate limit response */ rateLimited?: boolean; /** Consecutive 429 count for exponential backoff */ rateLimitedCount?: number; /** Absolute timestamp when the next rate-limited retry is allowed */ rateLimitedUntil?: number; /** Timestamp of the last successful API fetch (drives stale data cutoff) */ lastSuccessAt?: number; } interface OAuthCredentials { accessToken: string; expiresAt?: number; refreshToken?: string; /** Where the credentials were read from, needed for write-back */ source?: 'keychain' | 'file'; /** Keychain account name used when reading (null = service-only lookup) */ keychainAccount?: string | null; /** Subscription type from OAuth credentials (e.g. 'enterprise') */ subscriptionType?: string; /** Rate limit tier from OAuth credentials (e.g. 'default_claude_zero') */ rateLimitTier?: string; } interface UsageApiResponse { five_hour?: { utilization?: number; resets_at?: string }; seven_day?: { utilization?: number; resets_at?: string }; // Per-model quotas (flat structure at top level) seven_day_sonnet?: { utilization?: number; resets_at?: string }; seven_day_opus?: { utilization?: number; resets_at?: string }; // Extra (metered) usage for Pro subscribers extra_usage?: { utilization?: number; spent_usd?: number; limit_usd?: number; resets_at?: string; // Enterprise-specific fields is_enabled?: boolean; used_credits?: number; monthly_limit?: number | null; currency?: string; // ISO 4217 minor-unit exponent for `used_credits`/`monthly_limit` // (EUR=2, JPY=0, BHD=3). When present we no longer have to guess the scale. decimal_places?: number; }; // Generic per-bucket limits (replaces/supplements the flat seven_day_* keys on // newer accounts). Per-model weekly quotas arrive here as `kind: "weekly_scoped"` // entries keyed by `scope.model.display_name` rather than a fixed field name // (see issue #3576). limits?: Array<{ kind?: string; group?: string; percent?: number; is_active?: boolean; resets_at?: string; scope?: { model?: { id?: string | null; display_name?: string | null } | null; surface?: unknown; } | null; }>; } interface ParseUsageResponseOptions { /** Subscription type from OAuth credentials (for distinguishing Max/Pro overage from Enterprise billing) */ subscriptionType?: string | null; /** Rate limit tier from OAuth credentials; claude_zero tiers behave like Enterprise billing */ rateLimitTier?: string | null; } function isEnterpriseUsageContext(options?: ParseUsageResponseOptions): boolean { if (!options) return true; const subscriptionType = options.subscriptionType?.toLowerCase() ?? null; const rateLimitTier = options.rateLimitTier ?? null; if (subscriptionType == null && rateLimitTier == null) return true; return subscriptionType === 'enterprise' || /claude_zero/i.test(rateLimitTier ?? ''); } interface ZaiQuotaResponse { data?: { limits?: Array<{ type: string; // 'TOKENS_LIMIT' | 'TIME_LIMIT' percentage: number; // 0-100 remain_count?: number; quota_count?: number; currentValue?: number; usage?: number; nextResetTime?: number; // Unix timestamp in milliseconds // Window descriptor (undocumented by z.ai): unit=3 → 5h, unit=6 → weekly unit?: number; number?: number; }>; }; } // z.ai `unit` code for the weekly TOKENS_LIMIT bucket (observed, undocumented) const ZAI_UNIT_WEEK = 6; /** * Check if a URL points to z.ai (exact hostname match) */ export function isZaiHost(urlString: string): boolean { try { const url = new URL(urlString); const hostname = url.hostname.toLowerCase(); return hostname === 'z.ai' || hostname.endsWith('.z.ai'); } catch { return false; } } /** * Check if a URL points to MiniMax. * Matches all known MiniMax domains: * - minimax.io / *.minimax.io (international) * - minimaxi.com / *.minimaxi.com (China) * - minimax.com / *.minimax.com (China alternative) */ export function isMinimaxHost(urlString: string): boolean { try { const url = new URL(urlString); const hostname = url.hostname.toLowerCase(); return ( hostname === 'minimax.io' || hostname.endsWith('.minimax.io') || hostname === 'minimaxi.com' || hostname.endsWith('.minimaxi.com') || hostname === 'minimax.com' || hostname.endsWith('.minimax.com') ); } catch { return false; } } /** * Check if a URL points to the Kimi For Coding platform (kimi.com). * Matches kimi.com and any subdomain (e.g. api.kimi.com). The Moonshot open * platform (api.moonshot.ai / api.moonshot.cn) is intentionally NOT matched: * it exposes balance, not plan quota windows (no /usages endpoint). */ export function isKimiHost(urlString: string): boolean { try { const url = new URL(urlString); const hostname = url.hostname.toLowerCase(); return hostname === 'kimi.com' || hostname.endsWith('.kimi.com'); } catch { return false; } } /** * Kimi For Coding `/usages` payload (GET {origin}/coding/v1/usages). * Reverse-engineered from the open-source kimi-code CLI * (MoonshotAI/kimi-code, packages/oauth/src/managed-usage.ts) and verified * against the live endpoint with an API key. * * Quirk: `limit`/`used`/`remaining` arrive as JSON strings ("100"), not * numbers. `resetTime` is ISO 8601 with nano-precision fractional seconds. * * Shape (abridged live payload): * { * "usage": { "limit": "100", "used": "45", "remaining": "55", "resetTime": "..." }, // weekly window * "limits": [ * { "window": { "duration": 300, "timeUnit": "TIME_UNIT_MINUTE" }, // 5h window * "detail": { "limit": "100", "used": "2", "remaining": "98", "resetTime": "..." } } * ], * "boosterWallet": { ... } // optional extra (metered) monthly spend * } */ interface KimiQuotaRow { /** Fields are string-typed in the wire format; numbers tolerated for robustness */ limit?: number | string; used?: number | string; remaining?: number | string; /** ISO 8601, may carry nano-precision fraction (".628002Z") */ resetTime?: string; /** Aliases observed across payload versions (per kimi-code's loose parser) */ reset_at?: string; resetAt?: string; } /** * Hostnames allowed to receive the Kimi bearer token. * * `isKimiHost()` deliberately matches every `*.kimi.com` subdomain so provider * detection stays forgiving, but the credential itself must only ever travel to * the first-party origin Moonshot documents (`https://api.kimi.com/coding/`). * Pinning here keeps an attacker-influenced `ANTHROPIC_BASE_URL` — say a * takeover-prone or user-content `*.kimi.com` subdomain — from exfiltrating it. */ const KIMI_USAGE_HOSTNAMES = new Set(['api.kimi.com', 'kimi.com']); /** Fixed path of the Kimi usage endpoint; never derived from the environment */ const KIMI_USAGE_PATH = '/coding/v1/usages'; interface KimiUsageResponse { usage?: KimiQuotaRow; limits?: Array<{ window?: { duration?: number; timeUnit?: string }; detail?: KimiQuotaRow; } & KimiQuotaRow>; boosterWallet?: { balance?: { type?: string; amount?: number; amountLeft?: number }; monthlyChargeLimit?: { priceInCents?: number; currency?: string }; monthlyUsed?: { priceInCents?: number; currency?: string }; monthlyChargeLimitEnabled?: boolean; }; } interface MinimaxModelRemain { model_name: string; current_interval_total_count: number; /** Remaining request count in the current 5-hour window */ current_interval_usage_count: number; start_time: number; end_time: number; remains_time: number; current_weekly_total_count: number; /** Remaining request count in the current weekly window */ current_weekly_usage_count: number; weekly_start_time: number; weekly_end_time: number; weekly_remains_time: number; } interface MinimaxCodingPlanResponse { model_remains?: MinimaxModelRemain[]; base_resp?: { status_code: number; status_msg: string; }; } /** * Get the legacy (pre-split) cache file path */ function getLegacyCachePath(): string { return join(getClaudeConfigDir(), 'plugins', 'oh-my-claudecode', '.usage-cache.json'); } /** * Get the provider-specific cache file path */ function getCachePath(source: UsageSource): string { return join(getClaudeConfigDir(), 'plugins', 'oh-my-claudecode', `.usage-cache-${source}.json`); } /** * Migrate legacy single-file cache to provider-specific file. * One-shot: only runs when the provider-specific file does not yet exist * and the legacy cache's source matches the current provider. * Does NOT delete the legacy file (rolling update safety). */ function migrateLegacyCache(source: UsageSource): void { try { const legacyPath = getLegacyCachePath(); if (!existsSync(legacyPath)) return; // One-shot guard: skip if new file already exists if (existsSync(getCachePath(source))) return; const content = readFileSync(legacyPath, 'utf-8'); const cache = JSON.parse(content) as UsageCache; // Source mismatch guard: only migrate if legacy cache belongs to this provider if (cache.source !== source) return; const newPath = getCachePath(source); const cacheDir = dirname(newPath); if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }); } writeFileSync(newPath, content); } catch { // Best-effort migration — failures are harmless } } /** * Read cached usage data for a specific provider */ function readCache(source: UsageSource): UsageCache | null { try { const cachePath = getCachePath(source); if (!existsSync(cachePath)) return null; const content = readFileSync(cachePath, 'utf-8'); const cache = JSON.parse(content) as UsageCache; // Re-hydrate Date objects from JSON strings if (cache.data) { if (cache.data.fiveHourResetsAt) { cache.data.fiveHourResetsAt = new Date(cache.data.fiveHourResetsAt as unknown as string); } if (cache.data.weeklyResetsAt) { cache.data.weeklyResetsAt = new Date(cache.data.weeklyResetsAt as unknown as string); } if (cache.data.sonnetWeeklyResetsAt) { cache.data.sonnetWeeklyResetsAt = new Date(cache.data.sonnetWeeklyResetsAt as unknown as string); } if (cache.data.opusWeeklyResetsAt) { cache.data.opusWeeklyResetsAt = new Date(cache.data.opusWeeklyResetsAt as unknown as string); } if (cache.data.monthlyResetsAt) { cache.data.monthlyResetsAt = new Date(cache.data.monthlyResetsAt as unknown as string); } if (cache.data.extraUsageResetsAt) { cache.data.extraUsageResetsAt = new Date(cache.data.extraUsageResetsAt as unknown as string); } if (Array.isArray(cache.data.scopedWeeklyBuckets)) { for (const bucket of cache.data.scopedWeeklyBuckets) { const rawResetsAt = bucket?.resetsAt as unknown; if (rawResetsAt == null || rawResetsAt instanceof Date) continue; const parsedResetsAt = new Date(rawResetsAt as string); bucket.resetsAt = isNaN(parsedResetsAt.getTime()) ? null : parsedResetsAt; } } } return cache; } catch { return null; } } /** * Options for writing usage data to cache */ interface WriteCacheOptions { data: RateLimits | null; error?: boolean; source: UsageSource; rateLimited?: boolean; rateLimitedCount?: number; rateLimitedUntil?: number; errorReason?: UsageErrorReason; lastSuccessAt?: number; } /** * Write usage data to cache (provider-specific file) */ function writeCache(opts: WriteCacheOptions): void { try { const cachePath = getCachePath(opts.source); const cacheDir = dirname(cachePath); if (!existsSync(cacheDir)) { mkdirSync(cacheDir, { recursive: true }); } const cache: UsageCache = { timestamp: Date.now(), data: opts.data, error: opts.error, errorReason: opts.errorReason, source: opts.source, rateLimited: opts.rateLimited || undefined, rateLimitedCount: opts.rateLimitedCount && opts.rateLimitedCount > 0 ? opts.rateLimitedCount : undefined, rateLimitedUntil: opts.rateLimitedUntil, lastSuccessAt: opts.lastSuccessAt, }; writeFileSync(cachePath, JSON.stringify(cache, null, 2)); } catch { // Ignore cache write errors } } /** * Check if cache is still valid */ function sanitizePollIntervalMs(value: number | undefined): number { if (value == null || !Number.isFinite(value) || value <= 0) { return DEFAULT_HUD_USAGE_POLL_INTERVAL_MS; } return Math.max(1000, Math.floor(value)); } function getUsagePollIntervalMs(): number { try { return sanitizePollIntervalMs(readHudConfig().usageApiPollIntervalMs); } catch { return DEFAULT_HUD_USAGE_POLL_INTERVAL_MS; } } function getRateLimitedBackoffMs(pollIntervalMs: number, count: number): number { const normalizedPollIntervalMs = sanitizePollIntervalMs(pollIntervalMs); return Math.min( normalizedPollIntervalMs * Math.pow(2, Math.max(0, count - 1)), MAX_RATE_LIMITED_BACKOFF_MS, ); } function getTransientNetworkBackoffMs(pollIntervalMs: number): number { return Math.max(CACHE_TTL_TRANSIENT_NETWORK_MS, sanitizePollIntervalMs(pollIntervalMs)); } function isCacheValid(cache: UsageCache, pollIntervalMs: number): boolean { if (cache.rateLimited) { if (cache.rateLimitedUntil != null) { return Date.now() < cache.rateLimitedUntil; } const count = cache.rateLimitedCount || 1; return Date.now() - cache.timestamp < getRateLimitedBackoffMs(pollIntervalMs, count); } const ttl = cache.error ? cache.errorReason === 'network' ? getTransientNetworkBackoffMs(pollIntervalMs) : CACHE_TTL_FAILURE_MS : sanitizePollIntervalMs(pollIntervalMs); return Date.now() - cache.timestamp < ttl; } function hasUsableStaleData(cache: UsageCache | null | undefined): cache is UsageCache & { data: RateLimits } { if (!cache?.data) { return false; } if (cache.lastSuccessAt && Date.now() - cache.lastSuccessAt > MAX_STALE_DATA_MS) { return false; } return true; } function getCachedUsageResult(cache: UsageCache): UsageResult { if (cache.rateLimited) { if (!hasUsableStaleData(cache) && cache.data) { return { rateLimits: null, error: 'rate_limited' }; } return { rateLimits: cache.data, error: 'rate_limited', stale: cache.data ? true : undefined }; } if (cache.error) { const errorReason = cache.errorReason || 'network'; if (hasUsableStaleData(cache)) { return { rateLimits: cache.data, error: errorReason, stale: true }; } return { rateLimits: null, error: errorReason }; } return { rateLimits: cache.data }; } function createRateLimitedCacheEntry( source: UsageSource, data: RateLimits | null, pollIntervalMs: number, previousCount: number, lastSuccessAt?: number, ): UsageCache { const timestamp = Date.now(); const rateLimitedCount = previousCount + 1; return { timestamp, data, error: false, errorReason: 'rate_limited', source, rateLimited: true, rateLimitedCount, rateLimitedUntil: timestamp + getRateLimitedBackoffMs(pollIntervalMs, rateLimitedCount), lastSuccessAt, }; } /** * Get the Keychain service name for the current config directory. * Claude Code uses "Claude Code-credentials-{sha256(configDir)[:8]}" for * non-default dirs, where configDir is derived from the exact * CLAUDE_CONFIG_DIR value rather than the expanded filesystem path. Preserve * that behavior so ~-prefixed profiles keep matching Claude Code's own * Keychain entries. */ function getKeychainServiceName(): string { const configDir = process.env.CLAUDE_CONFIG_DIR; if (configDir) { const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8); return `Claude Code-credentials-${hash}`; } return 'Claude Code-credentials'; } function isCredentialExpired(creds: OAuthCredentials): boolean { return creds.expiresAt != null && creds.expiresAt <= Date.now(); } function readKeychainCredential(serviceName: string, account?: string): OAuthCredentials | null { try { const args = account ? ['find-generic-password', '-s', serviceName, '-a', account, '-w'] : ['find-generic-password', '-s', serviceName, '-w']; const result = execFileSync('/usr/bin/security', args, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); if (!result) return null; const parsed = JSON.parse(result); // Handle nested structure (claudeAiOauth wrapper) const creds = parsed.claudeAiOauth || parsed; if (!creds.accessToken) return null; return { accessToken: creds.accessToken, expiresAt: creds.expiresAt, refreshToken: creds.refreshToken, source: 'keychain' as const, keychainAccount: account ?? null, subscriptionType: creds.subscriptionType, rateLimitTier: creds.rateLimitTier, }; } catch { return null; } } /** * Read OAuth credentials from macOS Keychain */ function readKeychainCredentials(): OAuthCredentials | null { if (process.platform !== 'darwin') return null; const serviceName = getKeychainServiceName(); const candidateAccounts: Array = []; try { const username = userInfo().username?.trim(); if (username) { candidateAccounts.push(username); } } catch { // Best-effort only; fall back to the legacy service-only lookup below. } candidateAccounts.push(undefined); let expiredFallback: OAuthCredentials | null = null; for (const account of candidateAccounts) { const creds = readKeychainCredential(serviceName, account); if (!creds) continue; if (!isCredentialExpired(creds)) { return creds; } expiredFallback ??= creds; } return expiredFallback; } /** * Read OAuth credentials from file fallback */ function readFileCredentials(): OAuthCredentials | null { try { const credPath = join(getClaudeConfigDir(), '.credentials.json'); if (!existsSync(credPath)) return null; const content = readFileSync(credPath, 'utf-8'); const parsed = JSON.parse(content); // Handle nested structure (claudeAiOauth wrapper) const creds = parsed.claudeAiOauth || parsed; if (creds.accessToken) { return { accessToken: creds.accessToken, expiresAt: creds.expiresAt, refreshToken: creds.refreshToken, source: 'file' as const, subscriptionType: creds.subscriptionType, rateLimitTier: creds.rateLimitTier, }; } } catch { // File read failed } return null; } /** * Get OAuth credentials (Keychain first, then file fallback) */ function getCredentials(): OAuthCredentials | null { // Try Keychain first (macOS) const keychainCreds = readKeychainCredentials(); if (keychainCreds) return keychainCreds; // Fall back to file return readFileCredentials(); } /** * Get subscription info from OAuth credentials. * Returns subscriptionType and rateLimitTier (null when unavailable; never throws). */ export function getSubscriptionInfo(): { subscriptionType: string | null; rateLimitTier: string | null } { try { const creds = getCredentials(); return { subscriptionType: creds?.subscriptionType ?? null, rateLimitTier: creds?.rateLimitTier ?? null, }; } catch { return { subscriptionType: null, rateLimitTier: null }; } } /** * Validate credentials are not expired */ function validateCredentials(creds: OAuthCredentials): boolean { if (!creds.accessToken) return false; return !isCredentialExpired(creds); } /** * Attempt to refresh an expired OAuth access token using the refresh token. * Returns updated credentials on success, null on failure. */ function refreshAccessToken(refreshToken: string): Promise { return new Promise((resolve) => { const clientId = process.env.CLAUDE_CODE_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID; const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: clientId, }).toString(); const req = https.request( { hostname: TOKEN_REFRESH_URL_HOSTNAME, path: TOKEN_REFRESH_URL_PATH, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(body), }, timeout: API_TIMEOUT_MS, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 200) { try { const parsed = JSON.parse(data); if (parsed.access_token) { resolve({ accessToken: parsed.access_token, refreshToken: parsed.refresh_token || refreshToken, expiresAt: parsed.expires_in ? Date.now() + parsed.expires_in * 1000 : parsed.expires_at, }); return; } } catch { // JSON parse failed } } if (process.env.OMC_DEBUG) { console.error(`[usage-api] Token refresh failed: HTTP ${res.statusCode}`); } resolve(null); }); } ); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); req.end(body); }); } interface FetchResult { data: T | null; rateLimited?: boolean; } /** * Fetch usage from Anthropic API */ function fetchUsageFromApi(accessToken: string): Promise> { return new Promise((resolve) => { const req = https.request( { hostname: 'api.anthropic.com', path: '/api/oauth/usage', method: 'GET', headers: { 'Authorization': `Bearer ${accessToken}`, 'anthropic-beta': 'oauth-2025-04-20', 'Content-Type': 'application/json', }, timeout: API_TIMEOUT_MS, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 200) { try { resolve({ data: JSON.parse(data) }); } catch { resolve({ data: null }); } } else if (res.statusCode === 429) { if (process.env.OMC_DEBUG) { console.error(`[usage-api] Anthropic API returned 429 (rate limited)`); } resolve({ data: null, rateLimited: true }); } else { resolve({ data: null }); } }); } ); req.on('error', () => resolve({ data: null })); req.on('timeout', () => { req.destroy(); resolve({ data: null }); }); req.end(); }); } /** * Fetch usage from z.ai GLM API */ function fetchUsageFromZai(): Promise> { return new Promise((resolve) => { const baseUrl = process.env.ANTHROPIC_BASE_URL; const authToken = process.env.ANTHROPIC_AUTH_TOKEN; if (!baseUrl || !authToken) { resolve({ data: null }); return; } // Validate baseUrl for SSRF protection const validation = validateAnthropicBaseUrl(baseUrl); if (!validation.allowed) { console.error(`[SSRF Guard] Blocking usage API call: ${validation.reason}`); resolve({ data: null }); return; } try { const url = new URL(baseUrl); const baseDomain = `${url.protocol}//${url.host}`; const quotaLimitUrl = `${baseDomain}/api/monitor/usage/quota/limit`; const urlObj = new URL(quotaLimitUrl); const req = https.request( { hostname: urlObj.hostname, path: urlObj.pathname, method: 'GET', headers: { 'Authorization': authToken, 'Content-Type': 'application/json', 'Accept-Language': 'en-US,en', }, timeout: API_TIMEOUT_MS, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 200) { try { resolve({ data: JSON.parse(data) }); } catch { resolve({ data: null }); } } else if (res.statusCode === 429) { if (process.env.OMC_DEBUG) { console.error(`[usage-api] z.ai API returned 429 (rate limited)`); } resolve({ data: null, rateLimited: true }); } else { resolve({ data: null }); } }); } ); req.on('error', () => resolve({ data: null })); req.on('timeout', () => { req.destroy(); resolve({ data: null }); }); req.end(); } catch { resolve({ data: null }); } }); } /** * Persist refreshed credentials back to the Keychain. * Reads the existing Keychain entry, merges the updated fields, and writes it back. */ function writeKeychainCredentials(creds: OAuthCredentials): void { if (process.platform !== 'darwin') return; try { const serviceName = getKeychainServiceName(); const account = creds.keychainAccount ?? undefined; // Read the existing Keychain entry to preserve any extra fields const readArgs = account ? ['find-generic-password', '-s', serviceName, '-a', account, '-w'] : ['find-generic-password', '-s', serviceName, '-w']; let existing: Record = {}; try { const raw = execFileSync('/usr/bin/security', readArgs, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], }).trim(); if (raw) existing = JSON.parse(raw) as Record; } catch { // If we can't read it, we'll write a fresh entry } // Merge into the correct structure if (existing.claudeAiOauth && typeof existing.claudeAiOauth === 'object') { const inner = existing.claudeAiOauth as Record; inner.accessToken = creds.accessToken; if (creds.expiresAt != null) inner.expiresAt = creds.expiresAt; if (creds.refreshToken) inner.refreshToken = creds.refreshToken; } else { // Flat structure or empty (existing as Record).accessToken = creds.accessToken; if (creds.expiresAt != null) (existing as Record).expiresAt = creds.expiresAt; if (creds.refreshToken) (existing as Record).refreshToken = creds.refreshToken; } const newJson = JSON.stringify(existing); const writeArgs = account ? ['add-generic-password', '-s', serviceName, '-a', account, '-w', newJson, '-U'] : ['add-generic-password', '-s', serviceName, '-w', newJson, '-U']; execFileSync('/usr/bin/security', writeArgs, { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], }); } catch { // Silent failure - Keychain write-back is best-effort if (process.env.OMC_DEBUG) { console.error('[usage-api] Failed to write back refreshed credentials to Keychain'); } } } /** * Persist refreshed credentials back to the credential store. * When the credentials originated from Keychain, writes back to Keychain. * When they originated from file, updates ~/.claude/.credentials.json. * Updates only the OAuth token fields, preserving other data. */ function writeBackCredentials(creds: OAuthCredentials): void { if (creds.source === 'keychain') { writeKeychainCredentials(creds); return; } try { const credPath = join(getClaudeConfigDir(), '.credentials.json'); if (!existsSync(credPath)) return; const content = readFileSync(credPath, 'utf-8'); const parsed = JSON.parse(content); // Update the nested structure if (parsed.claudeAiOauth) { parsed.claudeAiOauth.accessToken = creds.accessToken; if (creds.expiresAt != null) { parsed.claudeAiOauth.expiresAt = creds.expiresAt; } if (creds.refreshToken) { parsed.claudeAiOauth.refreshToken = creds.refreshToken; } } else { // Flat structure parsed.accessToken = creds.accessToken; if (creds.expiresAt != null) { parsed.expiresAt = creds.expiresAt; } if (creds.refreshToken) { parsed.refreshToken = creds.refreshToken; } } // Atomic write: write to tmp file, then rename (atomic on POSIX, best-effort on Windows) const tmpPath = `${credPath}.tmp.${process.pid}`; try { writeFileSync(tmpPath, JSON.stringify(parsed, null, 2), { mode: 0o600 }); renameSync(tmpPath, credPath); } catch (writeErr) { // Clean up orphaned tmp file on failure try { if (existsSync(tmpPath)) { unlinkSync(tmpPath); } } catch { // Ignore cleanup errors } throw writeErr; } } catch { // Silent failure - credential write-back is best-effort if (process.env.OMC_DEBUG) { console.error('[usage-api] Failed to write back refreshed credentials'); } } } /** * Clamp values to 0-100 and filter invalid */ function clamp(v: number | undefined): number { if (v == null || !isFinite(v)) return 0; return Math.max(0, Math.min(100, v)); } /** * Result of resolving `response.limits[]` `kind: "weekly_scoped"` entries into * the typed Sonnet/Opus fields plus a generic bucket list for unrecognized * model families (see issue #3576). */ interface ScopedWeeklyResolution { sonnet?: { percent: number; resetsAt: Date | null }; opus?: { percent: number; resetsAt: Date | null }; generic: Array<{ id: string; label: string; percent: number; resetsAt: Date | null; isActive: boolean }>; } /** * Parse `response.limits[]` defensively into recognized Sonnet/Opus weekly * quotas plus a generic bucket list for unrecognized scoped weekly model * families (e.g. "Fable"). * * - Only `kind === "weekly_scoped"` entries are considered. * - Entries missing/malformed `scope.model.display_name` or a finite `percent` * are skipped rather than throwing. * - Family recognition is a case-insensitive substring match against * `display_name` (not an exact-case/enum match), so "Sonnet 4.5" or * "claude-sonnet" style names still map onto the Sonnet field. * - Duplicate buckets for the same `display_name` are deduped, preferring the * entry flagged `is_active: true`; `is_active` is used only for this * tiebreak, never to hide/filter a bucket, so an inactive-but-present scoped * quota still renders. * - When multiple *distinct* display names map onto the same recognized * family (e.g. two differently-named Sonnet tiers), the first one wins — * later ones do not overwrite an already-filled typed field. */ function resolveScopedWeeklyLimits( limits: UsageApiResponse['limits'], parseDate: (dateStr: string | undefined) => Date | null, ): ScopedWeeklyResolution { const result: ScopedWeeklyResolution = { generic: [] }; if (!Array.isArray(limits)) return result; // Dedup by normalized display_name, preferring the is_active entry. const byKey = new Map(); for (const entry of limits) { if (!entry || typeof entry !== 'object') continue; if (entry.kind !== 'weekly_scoped') continue; if (typeof entry.percent !== 'number' || !isFinite(entry.percent)) continue; const displayName = entry.scope?.model?.display_name; if (typeof displayName !== 'string' || displayName.trim() === '') continue; const key = displayName.trim().toLowerCase(); const isActive = entry.is_active === true; const existing = byKey.get(key); if (!existing || (isActive && !existing.isActive)) { byKey.set(key, { percent: entry.percent, resetsAt: entry.resets_at, isActive, modelId: entry.scope?.model?.id, displayName: displayName.trim(), }); } } for (const bucket of byKey.values()) { const percent = clamp(bucket.percent); const resetsAt = parseDate(bucket.resetsAt); const lower = bucket.displayName.toLowerCase(); const isSonnetFamily = lower.includes('sonnet'); const isOpusFamily = lower.includes('opus'); if (isSonnetFamily) { // First Sonnet-family entry wins; later distinct Sonnet-named entries are // dropped rather than falling through to the generic bucket (they refer // to the same recognized family, not an unrecognized one). if (result.sonnet == null) result.sonnet = { percent, resetsAt }; } else if (isOpusFamily) { if (result.opus == null) result.opus = { percent, resetsAt }; } else { const id = typeof bucket.modelId === 'string' && bucket.modelId.trim() !== '' ? bucket.modelId : lower; result.generic.push({ id, label: bucket.displayName, percent, resetsAt, isActive: bucket.isActive }); } } return result; } /** * Resolve the minor-unit exponent for `used_credits`/`monthly_limit`. * * The API annotates the currency's minor-unit exponent in `decimal_places` * (EUR=2, JPY=0, BHD=3 per ISO 4217), so we no longer have to guess the scale. * USD is implicitly 2-digit when the field is absent (long-standing behaviour). * Returns null when the scale is unknown (non-USD currency without * decimal_places), so callers skip the field rather than show a wrong figure. * * The exponent (not just the divisor) is carried through to the renderer so it * can format with the right number of decimals — ¥50,000 not ¥50,000.00. */ function minorUnitDecimals(currency: string, decimalPlaces?: number): number | null { // ISO 4217 minor-unit exponents are 0–4. Reject anything outside that range // (malformed/changed payload) so a bogus value can't reach toFixed(), which // throws a RangeError outside 0–100 — skip the field instead of crashing. if (decimalPlaces != null && Number.isInteger(decimalPlaces) && decimalPlaces >= 0 && decimalPlaces <= 4) { return decimalPlaces; } if (currency === 'USD') return 2; return null; } /** * Parse API response into RateLimits */ export function parseUsageResponse(response: UsageApiResponse, options?: ParseUsageResponseOptions): RateLimits | null { const fiveHour = response.five_hour?.utilization; const sevenDay = response.seven_day?.utilization; const sonnetSevenDay = response.seven_day_sonnet?.utilization; const opusSevenDay = response.seven_day_opus?.utilization; const extra = response.extra_usage; const usedCredits = extra?.used_credits; const extraCurrency = (extra?.currency ?? 'USD').toUpperCase(); const minorDecimals = minorUnitDecimals(extraCurrency, extra?.decimal_places); const minorDivisor = minorDecimals == null ? null : 10 ** minorDecimals; const isEnterpriseContext = isEnterpriseUsageContext(options); // Enterprise credits are usable once we know the minor-unit scale: USD, or any // currency the API annotated with decimal_places (minorDivisor != null). The // enterprise renderer is currency-aware (enterpriseCurrency). const hasUsableEnterprise = isEnterpriseContext && usedCredits != null && minorDivisor != null; const hasUsableUsdExtraUsage = extra?.limit_usd != null && extra.limit_usd > 0; // The Max/Pro overage renderer (limits.ts) hard-codes "$", so credit-shaped // overage stays USD-only until that renderer learns about currency. const hasUsableCreditExtraUsage = !isEnterpriseContext && usedCredits != null && extraCurrency === 'USD' && extra?.monthly_limit != null && extra.monthly_limit > 0; const hasUsableExtraUsage = hasUsableUsdExtraUsage || hasUsableCreditExtraUsage; // Parse ISO 8601 date strings to Date objects const parseDate = (dateStr: string | undefined): Date | null => { if (!dateStr) return null; try { const date = new Date(dateStr); return isNaN(date.getTime()) ? null : date; } catch { return null; } }; // Fall back to `limits[]` (`kind: "weekly_scoped"`) for per-model weekly quotas // when the legacy flat seven_day_sonnet/seven_day_opus fields are null/absent // (see issue #3576). Recognized families (sonnet/opus) fill the typed fields; // unrecognized model names (e.g. "Fable") become a generic bucket. const scopedWeekly = resolveScopedWeeklyLimits(response.limits, parseDate); // Need at least one valid value. Model-specific weekly buckets (flat or // limits[]-derived) are valid usage data even when generic subscription/window // metadata is absent or nullish. if ( fiveHour == null && sevenDay == null && sonnetSevenDay == null && opusSevenDay == null && !hasUsableEnterprise && !hasUsableExtraUsage && scopedWeekly.sonnet == null && scopedWeekly.opus == null && scopedWeekly.generic.length === 0 ) return null; // Per-model quotas are at the top level (flat structure) // e.g., response.seven_day_sonnet, response.seven_day_opus const sonnetResetsAt = response.seven_day_sonnet?.resets_at; const result: RateLimits = {}; if (fiveHour != null) { result.fiveHourPercent = clamp(fiveHour); result.fiveHourResetsAt = parseDate(response.five_hour?.resets_at); } if (sevenDay != null) { result.weeklyPercent = clamp(sevenDay); result.weeklyResetsAt = parseDate(response.seven_day?.resets_at); } // Add Sonnet-specific quota if available from API (flat field takes precedence; // limits[] weekly_scoped fallback only fills the gap when the flat field is // null/absent — never overwrites trustworthy old-shape data). if (sonnetSevenDay != null) { result.sonnetWeeklyPercent = clamp(sonnetSevenDay); result.sonnetWeeklyResetsAt = parseDate(sonnetResetsAt); } else if (scopedWeekly.sonnet != null) { result.sonnetWeeklyPercent = scopedWeekly.sonnet.percent; result.sonnetWeeklyResetsAt = scopedWeekly.sonnet.resetsAt; } // Add Opus-specific quota if available from API (same precedence as Sonnet above). const opusResetsAt = response.seven_day_opus?.resets_at; if (opusSevenDay != null) { result.opusWeeklyPercent = clamp(opusSevenDay); result.opusWeeklyResetsAt = parseDate(opusResetsAt); } else if (scopedWeekly.opus != null) { result.opusWeeklyPercent = scopedWeekly.opus.percent; result.opusWeeklyResetsAt = scopedWeekly.opus.resetsAt; } // Unrecognized scoped weekly model buckets (e.g. "Fable") render generically so // new tiers don't need a source release. if (scopedWeekly.generic.length > 0) { result.scopedWeeklyBuckets = scopedWeekly.generic; } // Add extra (metered) usage if available (Pro subscribers with extra usage allocation) if (extra != null) { // Enterprise path: used_credits (minor units) is present instead of spent_usd/limit_usd. // The scale comes from minorUnitDivisor — USD (implicitly 2-digit) or any currency // the API annotated with decimal_places (EUR=2, JPY=0, BHD=3 per ISO 4217). When the // scale is unknown (non-USD with no decimal_places) minorDivisor is null and we skip // the enterprise fields — the renderer then returns null rather than show a wrong figure. const currency = extraCurrency; if (extra.used_credits != null && minorDivisor != null && isEnterpriseContext) { result.enterpriseSpentUsd = extra.used_credits / minorDivisor; result.enterpriseLimitUsd = extra.monthly_limit == null ? null : extra.monthly_limit / minorDivisor; result.enterpriseCurrency = currency; if (minorDecimals != null) result.enterpriseDecimalPlaces = minorDecimals; // Only compute utilization when there is a positive cap if (extra.monthly_limit != null && extra.monthly_limit > 0) { result.enterpriseUtilization = clamp((extra.used_credits / extra.monthly_limit) * 100); } // resets_at not provided in enterprise response — leave enterpriseResetsAt unset } else if (extra.used_credits != null && currency === 'USD' && !isEnterpriseContext && extra.monthly_limit != null && extra.monthly_limit > 0) { // Max/Pro organization overage path: the API can use the enterprise-shaped // used_credits/monthly_limit fields even though the account should still render // normal token-window limits. Treat those minor-unit values as extra usage. const spentUsd = extra.used_credits / 100; result.extraUsageSpentUsd = spentUsd; result.extraUsageLimitUsd = extra.monthly_limit / 100; result.extraUsagePercent = extra.utilization != null ? clamp(extra.utilization) : clamp((extra.used_credits / extra.monthly_limit) * 100); result.extraUsageResetsAt = parseDate(extra.resets_at); } else if (extra.limit_usd != null && extra.limit_usd > 0) { // Pro metered path const spentUsd = extra.spent_usd ?? 0; result.extraUsageSpentUsd = spentUsd; result.extraUsageLimitUsd = extra.limit_usd; // Use API-provided utilization when available; fall back to spent/limit ratio result.extraUsagePercent = extra.utilization != null ? clamp(extra.utilization) : clamp((spentUsd / extra.limit_usd) * 100); result.extraUsageResetsAt = parseDate(extra.resets_at); } } return result; } /** * Parse z.ai API response into RateLimits. * * Weekly TOKENS_LIMIT exists only for plans purchased on/after 2026-02-12 * (UTC+8); older accounts return only the 5-hour bucket regardless of tier. * Classify by the entry's `unit` field (not nextResetTime) so buckets don't * swap near a weekly reset boundary; fall back to nextResetTime ordering * when `unit` is absent. */ export function parseZaiResponse(response: ZaiQuotaResponse): RateLimits | null { const limits = response.data?.limits; if (!limits || limits.length === 0) return null; type TokensLimit = NonNullable['limits']>[number]; const allTokensLimits = limits.filter(l => l.type === 'TOKENS_LIMIT'); const timeLimit = limits.find(l => l.type === 'TIME_LIMIT'); if (allTokensLimits.length === 0 && !timeLimit) return null; // Parse nextResetTime (Unix timestamp in milliseconds) to Date const parseResetTime = (timestamp: number | undefined): Date | null => { if (!timestamp) return null; try { const date = new Date(timestamp); return isNaN(date.getTime()) ? null : date; } catch { return null; } }; // Earlier reset wins 5h slot; equal reset, smaller percentage wins const sortByResetTime = (a: TokensLimit, b: TokensLimit): number => { const aTime = a.nextResetTime && a.nextResetTime > 0 ? a.nextResetTime : Infinity; const bTime = b.nextResetTime && b.nextResetTime > 0 ? b.nextResetTime : Infinity; if (aTime !== bTime) return aTime - bTime; return (a.percentage ?? 0) - (b.percentage ?? 0); }; const weeklyByUnit = allTokensLimits.find(l => l.unit === ZAI_UNIT_WEEK); let fiveHourBucket: TokensLimit | undefined; let weeklyBucket: TokensLimit | undefined; if (weeklyByUnit) { weeklyBucket = weeklyByUnit; fiveHourBucket = allTokensLimits .filter(l => l.unit !== ZAI_UNIT_WEEK) .slice() .sort(sortByResetTime)[0]; } else { // Legacy fallback: no unit field → sort all TOKENS_LIMIT by nextResetTime const sorted = allTokensLimits.slice().sort(sortByResetTime); fiveHourBucket = sorted[0]; weeklyBucket = sorted[1]; } if (allTokensLimits.length > 2 && process.env.OMC_DEBUG) { console.error( `[usage-api] z.ai returned ${allTokensLimits.length} TOKENS_LIMIT entries; using unit-based classification`, ); } const result: RateLimits = { fiveHourPercent: clamp(fiveHourBucket?.percentage), fiveHourResetsAt: parseResetTime(fiveHourBucket?.nextResetTime), monthlyPercent: timeLimit ? clamp(timeLimit.percentage) : undefined, monthlyResetsAt: timeLimit ? (parseResetTime(timeLimit.nextResetTime) ?? null) : undefined, }; if (weeklyBucket) { result.weeklyPercent = clamp(weeklyBucket.percentage); result.weeklyResetsAt = parseResetTime(weeklyBucket.nextResetTime); } return result; } /** * Fetch usage from MiniMax coding plan API */ function fetchUsageFromMinimax(apiKey: string): Promise> { return new Promise((resolve) => { const baseUrl = process.env.ANTHROPIC_BASE_URL; if (!baseUrl) { resolve({ data: null }); return; } // Validate baseUrl for SSRF protection const validation = validateAnthropicBaseUrl(baseUrl); if (!validation.allowed) { console.error(`[SSRF Guard] Blocking usage API call: ${validation.reason}`); resolve({ data: null }); return; } try { const url = new URL(baseUrl); const baseDomain = `${url.protocol}//${url.host}`; const quotaUrl = `${baseDomain}/v1/api/openplatform/coding_plan/remains`; const urlObj = new URL(quotaUrl); const req = https.request( { hostname: urlObj.hostname, path: urlObj.pathname, method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, timeout: API_TIMEOUT_MS, }, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 200) { try { resolve({ data: JSON.parse(data) }); } catch { resolve({ data: null }); } } else if (res.statusCode === 429) { if (process.env.OMC_DEBUG) { console.error(`[usage-api] MiniMax API returned 429 (rate limited)`); } resolve({ data: null, rateLimited: true }); } else { resolve({ data: null }); } }); } ); req.on('error', () => resolve({ data: null })); req.on('timeout', () => { req.destroy(); resolve({ data: null }); }); req.end(); } catch { resolve({ data: null }); } }); } /** * Parse MiniMax coding plan API response into RateLimits */ export function parseMinimaxResponse(response: MinimaxCodingPlanResponse): RateLimits | null { // Check for API error status if (response.base_resp?.status_code != null && response.base_resp.status_code !== 0) { return null; } const models = response.model_remains; if (!models || models.length === 0) return null; // Find the primary coding model (first match, case-insensitive) const codingModel = models.find(m => m.model_name.toLowerCase().startsWith('minimax-m')); if (!codingModel) { if (process.env.OMC_DEBUG) { console.error('[usage-api] No MiniMax-M* model found in coding plan response'); } return null; } // MiniMax's "remains" endpoint reports remaining quota, not consumed quota. // Convert remaining-count fields to used percentages for the HUD. const intervalTotal = codingModel.current_interval_total_count; const intervalUsed = intervalTotal - codingModel.current_interval_usage_count; const intervalPercent = intervalTotal > 0 ? (intervalUsed / intervalTotal) * 100 : 0; // Calculate weekly usage percentage from remaining weekly quota const weeklyTotal = codingModel.current_weekly_total_count; const weeklyUsed = weeklyTotal - codingModel.current_weekly_usage_count; const weeklyPercent = weeklyTotal > 0 ? (weeklyUsed / weeklyTotal) * 100 : 0; // Parse reset times from Unix ms timestamps const parseResetTime = (timestamp: number | undefined): Date | null => { if (!timestamp) return null; try { const date = new Date(timestamp); return isNaN(date.getTime()) ? null : date; } catch { return null; } }; return { fiveHourPercent: clamp(intervalPercent), fiveHourResetsAt: parseResetTime(codingModel.end_time), weeklyPercent: clamp(weeklyPercent), weeklyResetsAt: parseResetTime(codingModel.weekly_end_time), }; } /** * Fetch usage from the Kimi For Coding platform. * * Endpoint: GET https://{canonical kimi host}/coding/v1/usages. Only the host * comes from ANTHROPIC_BASE_URL (whose path may be /coding, /coding/, or * /coding/v1 depending on setup docs) and it must be one of * KIMI_USAGE_HOSTNAMES — the bearer token never leaves that origin. * Auth: Bearer token — accepts both Kimi API keys (METHOD_API_KEY) and OAuth * access tokens from the kimi-code CLI. */ function fetchUsageFromKimi(apiKey: string): Promise> { return new Promise((resolve) => { const baseUrl = process.env.ANTHROPIC_BASE_URL; if (!baseUrl) { resolve({ data: null }); return; } // Validate baseUrl for SSRF protection const validation = validateAnthropicBaseUrl(baseUrl); if (!validation.allowed) { console.error(`[SSRF Guard] Blocking usage API call: ${validation.reason}`); resolve({ data: null }); return; } try { const hostname = new URL(baseUrl).hostname.toLowerCase(); // Provider detection accepts any *.kimi.com host; the credential does not. if (!KIMI_USAGE_HOSTNAMES.has(hostname)) { if (process.env.OMC_DEBUG) { console.error( `[usage-api] Refusing to send Kimi credentials to non-canonical host '${hostname}'`, ); } resolve({ data: null }); return; } const req = https.request( { hostname, path: KIMI_USAGE_PATH, method: 'GET', headers: { 'Authorization': `Bearer ${apiKey}`, 'Accept': 'application/json', }, timeout: API_TIMEOUT_MS, }, (res) => { let data = ''; // A socket reset *after* headers arrive surfaces on the response // stream, not the request. Without these listeners that 'error' is // unhandled and takes the HUD process down instead of degrading to a // network failure. resolve() past the first call is a no-op. res.on('error', () => resolve({ data: null })); res.on('aborted', () => resolve({ data: null })); res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { if (res.statusCode === 200) { try { resolve({ data: JSON.parse(data) }); } catch { resolve({ data: null }); } } else if (res.statusCode === 429) { if (process.env.OMC_DEBUG) { console.error(`[usage-api] Kimi API returned 429 (rate limited)`); } resolve({ data: null, rateLimited: true }); } else { resolve({ data: null }); } }); } ); req.on('error', () => resolve({ data: null })); req.on('timeout', () => { req.destroy(); resolve({ data: null }); }); req.end(); } catch { resolve({ data: null }); } }); } /** * Parse a Kimi quota row's number-ish field (wire format uses strings). */ function kimiToNumber(value: number | string | undefined): number | null { if (typeof value === 'number') { return Number.isFinite(value) ? value : null; } if (typeof value === 'string' && value.length > 0) { const n = Number(value); return Number.isFinite(n) ? n : null; } return null; } /** * Parse a Kimi resetTime/reset_at/resetAt ISO string into a Date. * Trims nano-precision fractions to milliseconds so Date.parse never chokes. */ function parseKimiResetTime(row: KimiQuotaRow | undefined): Date | null { const raw = row?.resetTime ?? row?.reset_at ?? row?.resetAt; // Runtime payloads are untrusted: a non-string reset field must not throw // (parseFn exceptions would escape fetchAndCacheUsage into the HUD). if (typeof raw !== 'string' || raw.length === 0) return null; let normalized = raw; if (normalized.includes('.') && normalized.endsWith('Z')) { const [base, frac] = normalized.slice(0, -1).split('.'); if (base && frac) { normalized = `${base}.${frac.slice(0, 3)}Z`; } } try { const date = new Date(normalized); return isNaN(date.getTime()) ? null : date; } catch { return null; } } /** * Normalize a Kimi currency code to upper case, or null when absent. * Non-string values are rejected rather than coerced: runtime payloads are * untrusted and `.toUpperCase()` on a number would throw into the HUD. */ function kimiCurrencyCode(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value.toUpperCase() : null; } /** * Used quota for a Kimi row: direct `used`, or `limit - remaining` fallback * (mirrors kimi-code's loose parser — field spelling drifted across versions). */ function kimiUsedQuota(row: KimiQuotaRow): number | null { const used = kimiToNumber(row.used); if (used != null) return used; const limit = kimiToNumber(row.limit); const remaining = kimiToNumber(row.remaining); if (limit != null && remaining != null) return limit - remaining; return null; } /** * Convert a Kimi limits[] window descriptor to total minutes, or null when * the window shape is unknown (duration missing or unrecognized timeUnit). */ function kimiWindowMinutes(window: { duration?: number; timeUnit?: string } | undefined): number | null { const duration = window?.duration; // Untrusted payload: a non-string timeUnit must not throw on .includes() const rawUnit = window?.timeUnit; const timeUnit = typeof rawUnit === 'string' ? rawUnit : ''; if (duration == null || !Number.isFinite(duration) || duration <= 0) return null; if (timeUnit.includes('MINUTE')) return duration; if (timeUnit.includes('HOUR')) return duration * 60; if (timeUnit.includes('DAY')) return duration * 60 * 24; return null; } /** * Duration of the Kimi For Coding rolling window the HUD renders as "5h". * * Kimi documents a rolling 5-hour rate window on top of the weekly quota, and * the live payload reports it as `{ duration: 300, timeUnit: TIME_UNIT_MINUTE }`. * Only this exact duration may fill `fiveHourPercent`: the HUD prints that field * under a hard-coded "5h" label, so accepting a nearby window (a 1h burst cap, * say) would report one product limit while claiming another. */ const KIMI_FIVE_HOUR_WINDOW_MINUTES = 300; /** * Parse Kimi For Coding `/usages` response into RateLimits. * * Mapping (verified against live payload): * - Top-level `usage` → weekly window (resetTime ~7 days out) * - `limits[]` entry whose window is exactly 300 minutes → 5-hour window * (observed: window.duration=300, timeUnit=TIME_UNIT_MINUTE). Any other * duration is dropped, never rendered under the HUD's "5h" label. * - `boosterWallet` (optional) → extra usage, USD only: the HUD's extra-usage * renderer hard-codes "$", so CNY wallets are skipped rather than mislabeled. */ export function parseKimiResponse(response: KimiUsageResponse): RateLimits | null { // fiveHourPercent is intentionally left unset: seeding 0 would render as // "5h:0%" for a payload that carried no 5-hour window at all, asserting zero // usage of a quota we have no data for. const result: RateLimits = {}; let hasAny = false; // Weekly window: top-level usage row const weekly = response.usage; if (weekly) { const limit = kimiToNumber(weekly.limit); const used = kimiUsedQuota(weekly); if (limit != null && limit > 0 && used != null) { result.weeklyPercent = clamp((used / limit) * 100); result.weeklyResetsAt = parseKimiResetTime(weekly); hasAny = true; } } // 5-hour window: the limits[] entry whose window is exactly 5 hours. Windows // of any other duration (and unclassifiable ones) are left out rather than // relabelled — see KIMI_FIVE_HOUR_WINDOW_MINUTES. const limits = Array.isArray(response.limits) ? response.limits : []; let filledFiveHour = false; let sawOtherWindow = false; for (const entry of limits) { if (!entry || typeof entry !== 'object') continue; if (kimiWindowMinutes(entry.window) !== KIMI_FIVE_HOUR_WINDOW_MINUTES) { sawOtherWindow = true; continue; } const row = entry.detail ?? entry; const limit = kimiToNumber(row.limit); const used = kimiUsedQuota(row); // Keep scanning on an unusable row: a duplicate 5h entry may still be good if (limit == null || limit <= 0 || used == null) continue; result.fiveHourPercent = clamp((used / limit) * 100); result.fiveHourResetsAt = parseKimiResetTime(row); hasAny = true; filledFiveHour = true; break; } if (!filledFiveHour && sawOtherWindow && process.env.OMC_DEBUG) { console.error( `[usage-api] Kimi limits[] carried no ${KIMI_FIVE_HOUR_WINDOW_MINUTES}-minute window — 5h bucket left empty`, ); } // Extra (metered) usage: booster wallet monthly spend vs monthly cap. // Monthly figures arrive as priceInCents. USD-only — see comment above; an // absent currency is skipped too (never guess "$" for an unknown currency). const wallet = response.boosterWallet; if (wallet?.balance?.type === 'BOOSTER') { const limitCents = kimiToNumber(wallet.monthlyChargeLimit?.priceInCents); const usedCents = kimiToNumber(wallet.monthlyUsed?.priceInCents); // Both sides must declare USD independently. Falling back from one to the // other would render a mismatched pair (limit USD / used CNY) as one "$" // figure over another, silently misstating the spend. const limitCurrency = kimiCurrencyCode(wallet.monthlyChargeLimit?.currency); const usedCurrency = kimiCurrencyCode(wallet.monthlyUsed?.currency); if ( wallet.monthlyChargeLimitEnabled === true && limitCents != null && limitCents > 0 && usedCents != null && limitCurrency === 'USD' && usedCurrency === 'USD' ) { result.extraUsageSpentUsd = usedCents / 100; result.extraUsageLimitUsd = limitCents / 100; result.extraUsagePercent = clamp((usedCents / limitCents) * 100); hasAny = true; } } return hasAny ? result : null; } /** * Generic provider fetch-and-cache cycle. * Handles 429 backoff, stale data fallback, and cache writes. * Provider-specific pre-fetch logic (e.g., credential refresh) runs before calling this. */ async function fetchAndCacheUsage(opts: { source: UsageSource; fetchFn: () => Promise>; parseFn: (data: T) => RateLimits | null; cache: UsageCache | null; pollIntervalMs: number; }): Promise { const { source, fetchFn, parseFn, cache, pollIntervalMs } = opts; const result = await fetchFn(); if (result.rateLimited) { const prevLastSuccess = cache?.lastSuccessAt; const rateLimitedCache = createRateLimitedCacheEntry(source, cache?.data || null, pollIntervalMs, cache?.rateLimitedCount || 0, prevLastSuccess); writeCache({ data: rateLimitedCache.data, error: rateLimitedCache.error, source, rateLimited: true, rateLimitedCount: rateLimitedCache.rateLimitedCount, rateLimitedUntil: rateLimitedCache.rateLimitedUntil, errorReason: 'rate_limited', lastSuccessAt: rateLimitedCache.lastSuccessAt, }); if (rateLimitedCache.data) { if (prevLastSuccess && Date.now() - prevLastSuccess > MAX_STALE_DATA_MS) { return { rateLimits: null, error: 'rate_limited' }; } return { rateLimits: rateLimitedCache.data, error: 'rate_limited', stale: true }; } return { rateLimits: null, error: 'rate_limited' }; } if (!result.data) { const fallbackData = hasUsableStaleData(cache) ? cache.data : null; writeCache({ data: fallbackData, error: true, source, errorReason: 'network', lastSuccessAt: cache?.lastSuccessAt, }); if (fallbackData) { return { rateLimits: fallbackData, error: 'network', stale: true }; } return { rateLimits: null, error: 'network' }; } const usage = parseFn(result.data); writeCache({ data: usage, error: !usage, source, lastSuccessAt: Date.now() }); return { rateLimits: usage }; } /** * Get usage data (with caching) * * Returns a UsageResult with: * - rateLimits: RateLimits on success, null on failure/no credentials * - error: categorized reason when API call fails (undefined on success or no credentials) * - 'network': API call failed (timeout, HTTP error, parse error) * - 'auth': credentials expired and refresh failed * - 'no_credentials': no OAuth credentials available (expected for API key users) * - 'rate_limited': API returned 429; stale data served if available, with exponential backoff */ export async function getUsage(): Promise { const baseUrl = process.env.ANTHROPIC_BASE_URL; const authToken = process.env.ANTHROPIC_AUTH_TOKEN; const isMinimax = baseUrl != null && isMinimaxHost(baseUrl); const isKimi = baseUrl != null && isKimiHost(baseUrl); const isZai = baseUrl != null && isZaiHost(baseUrl); const minimaxApiKey = process.env.MINIMAX_API_KEY || authToken; // Kimi For Coding documents `ANTHROPIC_BASE_URL=https://api.kimi.com/coding/` // paired with `ANTHROPIC_API_KEY`; the Moonshot open platform and the // kimi-code CLI use `ANTHROPIC_AUTH_TOKEN` instead. Both reach this branch, // so try them in order of specificity to this host: // 1. KIMI_API_KEY — explicit, provider-scoped override // 2. ANTHROPIC_API_KEY — the documented credential for api.kimi.com // 3. ANTHROPIC_AUTH_TOKEN — OAuth access tokens / platform-style setups // (2) outranks (3) because Moonshot warns the two conflict and tells users on // this endpoint to unset ANTHROPIC_AUTH_TOKEN — a leftover one must not mask // the key the documented setup actually authenticates with. const kimiApiKey = process.env.KIMI_API_KEY || process.env.ANTHROPIC_API_KEY || authToken; const currentSource: UsageSource = isMinimax ? 'minimax' : isKimi ? 'kimi' : isZai && authToken ? 'zai' : 'anthropic'; const pollIntervalMs = getUsagePollIntervalMs(); // Migrate legacy single-file cache to provider-specific file (one-shot, best-effort) migrateLegacyCache(currentSource); const initialCache = readCache(currentSource); if (initialCache && isCacheValid(initialCache, pollIntervalMs) && initialCache.source === currentSource) { return getCachedUsageResult(initialCache); } try { return await withFileLock(lockPathFor(getCachePath(currentSource)), async () => { const cache = readCache(currentSource); if (cache && isCacheValid(cache, pollIntervalMs) && cache.source === currentSource) { return getCachedUsageResult(cache); } // MiniMax path (must precede z.ai and OAuth checks) if (isMinimax) { if (!minimaxApiKey) { writeCache({ data: null, error: true, source: 'minimax', errorReason: 'no_credentials' }); return { rateLimits: null, error: 'no_credentials' }; } return fetchAndCacheUsage({ source: 'minimax', fetchFn: () => fetchUsageFromMinimax(minimaxApiKey), parseFn: parseMinimaxResponse, cache, pollIntervalMs, }); } // Kimi path (must precede z.ai and OAuth checks) if (isKimi) { if (!kimiApiKey) { writeCache({ data: null, error: true, source: 'kimi', errorReason: 'no_credentials' }); return { rateLimits: null, error: 'no_credentials' }; } return fetchAndCacheUsage({ source: 'kimi', fetchFn: () => fetchUsageFromKimi(kimiApiKey), parseFn: parseKimiResponse, cache, pollIntervalMs, }); } // z.ai path (must precede OAuth check to avoid stale Anthropic credentials) if (isZai && authToken) { return fetchAndCacheUsage({ source: 'zai', fetchFn: () => fetchUsageFromZai(), parseFn: parseZaiResponse, cache, pollIntervalMs, }); } // Anthropic OAuth path (official Claude Code support) let creds = getCredentials(); if (creds) { if (!validateCredentials(creds)) { if (creds.refreshToken) { const refreshed = await refreshAccessToken(creds.refreshToken); if (refreshed) { creds = { ...creds, ...refreshed }; writeBackCredentials(creds); } else { writeCache({ data: null, error: true, source: 'anthropic', errorReason: 'auth' }); return { rateLimits: null, error: 'auth' }; } } else { writeCache({ data: null, error: true, source: 'anthropic', errorReason: 'auth' }); return { rateLimits: null, error: 'auth' }; } } const accessToken = creds.accessToken; const subscriptionType = creds.subscriptionType; const rateLimitTier = creds.rateLimitTier; return fetchAndCacheUsage({ source: 'anthropic', fetchFn: () => fetchUsageFromApi(accessToken), parseFn: (data) => parseUsageResponse(data, { subscriptionType, rateLimitTier, }), cache, pollIntervalMs, }); } writeCache({ data: null, error: true, source: 'anthropic', errorReason: 'no_credentials' }); return { rateLimits: null, error: 'no_credentials' }; }, USAGE_CACHE_LOCK_OPTS); } catch (err) { // Lock acquisition failed — return stale cache without touching the cache file // to avoid racing with the lock holder writing fresh data if (err instanceof Error && err.message.startsWith('Failed to acquire file lock')) { if (initialCache?.data) { return { rateLimits: initialCache.data, stale: true }; } return { rateLimits: null, error: 'network' }; } return { rateLimits: null, error: 'network' }; } }