import { describe, it, beforeEach } from 'node:test'; import assert from 'node:assert/strict'; import { extractUsage, recordUsage, getRunUsage, resetRunUsage, recordRateLimit, getRateLimit, resetRateLimit, resolveRemainingPercent, fetchAccountQuota, formatUsageStats, formatUsageStatsLine, } from './usage.js'; describe('extractUsage', () => { it('parses OpenAI-compatible usage', () => { const u = extractUsage({ usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }); assert.deepEqual(u, { promptTokens: 100, completionTokens: 20, totalTokens: 120 }); }); it('parses OpenAI Responses input/output tokens and derives total', () => { const u = extractUsage({ usage: { input_tokens: 50, output_tokens: 10 } }); assert.deepEqual(u, { promptTokens: 50, completionTokens: 10, totalTokens: 60 }); }); it('parses Gemini usageMetadata', () => { const u = extractUsage({ usageMetadata: { promptTokenCount: 30, candidatesTokenCount: 5, totalTokenCount: 35 } }); assert.deepEqual(u, { promptTokens: 30, completionTokens: 5, totalTokens: 35 }); }); it('parses Ollama native eval counts', () => { const u = extractUsage({ prompt_eval_count: 12, eval_count: 8 }); assert.deepEqual(u, { promptTokens: 12, completionTokens: 8, totalTokens: 20 }); }); it('parses OpenCode tokens from info.tokens', () => { const u = extractUsage({ info: { tokens: { input: 7, output: 3 } } }); assert.deepEqual(u, { promptTokens: 7, completionTokens: 3, totalTokens: 10 }); }); it('respects an explicit total_tokens of 0 instead of summing', () => { const u = extractUsage({ usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 0 } }); assert.equal(u.totalTokens, 0); }); it('returns null when no usage info is present', () => { assert.equal(extractUsage({ choices: [{ message: { content: 'hi' } }] }), null); assert.equal(extractUsage(null), null); }); }); describe('recordUsage / getRunUsage', () => { beforeEach(() => resetRunUsage()); it('accumulates across calls and counts every call', () => { recordUsage({ usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 } }); recordUsage({ usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 } }); recordUsage({ parts: [] }); // no usage → still counts as a call assert.deepEqual(getRunUsage(), { calls: 3, promptTokens: 15, completionTokens: 3, totalTokens: 18 }); }); it('returns a copy, not the internal object', () => { const a = getRunUsage(); a.calls = 999; assert.equal(getRunUsage().calls, 0); }); }); describe('fetchAccountQuota', () => { it('reads OpenRouter credits via injected get', async () => { const get = async (url, opts) => { assert.match(url, /openrouter\.ai\/api\/v1\/auth\/key$/); assert.equal(opts.headers.Authorization, 'Bearer sk-or-xxx'); return { data: { data: { usage: 12.4, limit: 100, limit_remaining: 87.6 } } }; }; const q = await fetchAccountQuota('openai', { apiKeys: ['sk-or-xxx'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); assert.deepEqual(q, { available: true, used: 12.4, limit: 100, remaining: 87.6, currency: 'USD', source: 'openrouter' }); }); it('derives remaining when OpenRouter omits limit_remaining', async () => { const get = async () => ({ data: { data: { usage: 10, limit: 50 } } }); const q = await fetchAccountQuota('openai', { apiKeys: ['k'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); assert.equal(q.remaining, 40); }); it('reports unavailable for plain OpenAI (no openrouter)', async () => { const q = await fetchAccountQuota('openai', { apiKeys: ['k'], baseURL: 'https://api.openai.com/v1' }, { get: async () => { throw new Error('should not call'); } }); assert.equal(q.available, false); assert.match(q.reason, /API key 無法取得/); }); it('does not treat spoofed openrouter hostnames as OpenRouter (no key leak)', async () => { const get = async () => { throw new Error('should not be called for spoofed host'); }; for (const baseURL of ['https://openrouter.ai.evil.com/api/v1', 'https://evil.com/openrouter.ai']) { const q = await fetchAccountQuota('openai', { apiKeys: ['sk-secret'], baseURL }, { get }); assert.equal(q.available, false); assert.match(q.reason, /API key 無法取得/); } }); it('only accepts the exact openrouter.ai apex host (subdomains are not OpenRouter)', async () => { const get = async () => { throw new Error('should not be called for non-apex host'); }; const q = await fetchAccountQuota('openai', { apiKeys: ['sk-secret'], baseURL: 'https://api.openrouter.ai/api/v1' }, { get }); assert.equal(q.available, false); assert.match(q.reason, /API key 無法取得/); }); it('reports 不適用 for local platforms', async () => { assert.equal((await fetchAccountQuota('ollama', {})).available, false); assert.equal((await fetchAccountQuota('opencode', {})).available, false); }); it('degrades gracefully when the quota call throws', async () => { const get = async () => { throw new Error('network down'); }; const q = await fetchAccountQuota('openai', { apiKeys: ['k'], baseURL: 'https://openrouter.ai/api/v1' }, { get }); assert.deepEqual(q, { available: false, reason: 'network down' }); }); it('reports unsupported provider', async () => { const q = await fetchAccountQuota('mystery', {}); assert.equal(q.available, false); assert.match(q.reason, /未支援/); }); it('degrades gracefully when apiKeys is empty or undefined', async () => { const get = async () => { throw new Error('should not be called'); }; // 空陣列 / 未提供 key 都不應丟錯,依平台回報無法取得或不適用 assert.equal((await fetchAccountQuota('openai', { apiKeys: [], baseURL: 'https://api.openai.com/v1' }, { get })).available, false); assert.equal((await fetchAccountQuota('ollama', { apiKeys: [] }, { get })).available, false); assert.equal((await fetchAccountQuota('claude', {}, { get })).available, false); }); }); describe('recordRateLimit / getRateLimit', () => { beforeEach(() => resetRateLimit()); it('captures OpenAI-style token rate-limit headers (case-insensitive)', () => { recordRateLimit({ 'X-RateLimit-Remaining-Tokens': '190000', 'X-RateLimit-Limit-Tokens': '200000' }); assert.deepEqual(getRateLimit(), { hasData: true, remaining: 190000, limit: 200000, kind: 'tokens' }); }); it('captures Anthropic-style token rate-limit headers', () => { recordRateLimit({ 'anthropic-ratelimit-tokens-remaining': '8000', 'anthropic-ratelimit-tokens-limit': '10000' }); assert.deepEqual(getRateLimit(), { hasData: true, remaining: 8000, limit: 10000, kind: 'tokens' }); }); it('falls back to request-dimension headers when token headers are absent', () => { recordRateLimit({ 'x-ratelimit-remaining-requests': '45', 'x-ratelimit-limit-requests': '60' }); assert.deepEqual(getRateLimit(), { hasData: true, remaining: 45, limit: 60, kind: 'requests' }); }); it('ignores responses without rate-limit headers', () => { recordRateLimit({ 'content-type': 'application/json' }); assert.equal(getRateLimit().hasData, false); }); }); describe('resolveRemainingPercent', () => { it('prefers account quota when a finite limit exists', () => { const pct = resolveRemainingPercent({ available: true, used: 12.4, limit: 100, remaining: 87.6, currency: 'USD' }, { hasData: true, remaining: 1, limit: 10, kind: 'tokens' }); assert.equal(pct.percent, 87.6); assert.equal(pct.basis, '帳號額度'); }); it('derives remaining from used when quota.remaining is absent', () => { const pct = resolveRemainingPercent({ available: true, used: 25, limit: 100, currency: 'USD' }, null); assert.equal(pct.percent, 75); }); it('falls back to rate-limit window percent when quota has no limit', () => { const pct = resolveRemainingPercent({ available: false, reason: 'x' }, { hasData: true, remaining: 150000, limit: 200000, kind: 'tokens' }); assert.equal(pct.percent, 75); assert.match(pct.basis, /速率配額(當前視窗,token)/); }); it('returns null percent with a reason when nothing is available', () => { const pct = resolveRemainingPercent({ available: false, reason: '本地服務,無帳號額度概念' }, { hasData: false }); assert.equal(pct.percent, null); assert.equal(pct.reason, '本地服務,無帳號額度概念'); }); it('explains an unlimited account cannot yield a percent', () => { const pct = resolveRemainingPercent({ available: true, used: 5, limit: null }, { hasData: false }); assert.equal(pct.percent, null); assert.match(pct.reason, /無上限/); }); it('does not divide by zero when quota.limit is 0', () => { const pct = resolveRemainingPercent({ available: true, used: 5, limit: 0, currency: 'USD' }, { hasData: false }); assert.equal(pct.percent, null); // limit > 0 守衛擋掉除以零 assert.ok(typeof pct.reason === 'string' && pct.reason.length > 0); }); }); describe('formatUsageStats', () => { const usage = { calls: 7, promptTokens: 18432, completionTokens: 2107, totalTokens: 20539 }; it('renders token table and remaining percent from account quota', () => { const out = formatUsageStats('openai', 'gpt-4o-mini', usage, { available: true, used: 12.4, limit: 100, remaining: 87.6, currency: 'USD' }, null); assert.match(out, /## 🤖 AI 助理使用量/); assert.match(out, /18,432 \| 2,107 \| 20,539/); assert.match(out, /共 7 次呼叫/); assert.match(out, /剩餘可用 \*\*87.6%\*\*(帳號額度:USD 87.6 \/ USD 100)/); }); it('renders remaining percent from rate-limit window when quota is unavailable', () => { const out = formatUsageStats('claude', 'sonnet', usage, { available: false, reason: 'r' }, { hasData: true, remaining: 150000, limit: 200000, kind: 'tokens' }); assert.match(out, /剩餘可用 \*\*75%\*\*(速率配額(當前視窗,token):150,000 \/ 200,000)/); }); it('explains when no percentage can be computed', () => { const out = formatUsageStats('ollama', 'llama3', usage, { available: false, reason: '本地服務,無帳號額度概念' }, { hasData: false }); assert.match(out, /剩餘可用:無法計算百分比(本地服務,無帳號額度概念)/); }); }); describe('formatUsageStatsLine', () => { const usage = { calls: 3, promptTokens: 100, completionTokens: 20, totalTokens: 120 }; it('summarises tokens and remaining percent on one line', () => { const line = formatUsageStatsLine('openai', 'gpt-4o-mini', usage, { available: true, used: 1, limit: 10, remaining: 9, currency: 'USD' }, null); assert.equal(line, '本次 openai/gpt-4o-mini: 提示100 + 回應20 = 120 token(3 次呼叫);剩餘可用: 90%(帳號額度 USD 9/USD 10)'); }); it('notes when remaining percent cannot be computed', () => { const line = formatUsageStatsLine('ollama', 'llama3', usage, { available: false, reason: '本地服務,無帳號額度概念' }, { hasData: false }); assert.match(line, /;剩餘可用: 無法計算(本地服務,無帳號額度概念)/); }); });