197 lines
9.1 KiB
JavaScript
197 lines
9.1 KiB
JavaScript
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('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('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, /未支援/);
|
||
});
|
||
});
|
||
|
||
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, /無上限/);
|
||
});
|
||
});
|
||
|
||
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, /;剩餘可用: 無法計算(本地服務,無帳號額度概念)/);
|
||
});
|
||
});
|