Files
code-review/app/usage.test.js
T

232 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 token3 次呼叫);剩餘可用: 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, /;剩餘可用: 無法計算(本地服務,無帳號額度概念)/);
});
});