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

343 lines
15 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 axios from 'axios';
import { getInsecureHttpsAgent } from './config.js';
import { warn } from './log.js';
/** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */
const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 0 };
/**
* 安全數字轉換:將任意輸入轉為有限數字,無法轉換或非有限值(NaN/Infinity)一律回 0。
* 常用於正規化外部 API 回應或 HTTP header 取出的值,避免污染後續加總與百分比運算。
* @param {*} x 任意待轉換的值。
* @returns {number} 有限數字;否則為 0。
*/
function num(x) {
const n = Number(x);
return Number.isFinite(n) ? n : 0;
}
/**
* 把各平台回應中的 token usage 正規化成 { promptTokens, completionTokens, totalTokens }。
* 支援:OpenAI 相容 usage、OpenAI Responsesinput/output_tokens)、
* Gemini usageMetadata、Ollama 原生 eval_count、OpenCode tokens。
* 回應中沒有任何可辨識的 usage 時回傳 null。
*/
export function extractUsage(data) {
if (!data || typeof data !== 'object') return null;
// OpenAI 相容 / OpenAI Responses
const u = data.usage;
if (u && typeof u === 'object') {
const prompt = num(u.prompt_tokens ?? u.input_tokens);
const completion = num(u.completion_tokens ?? u.output_tokens);
const total = u.total_tokens != null ? num(u.total_tokens) : prompt + completion;
if (prompt || completion || total) return { promptTokens: prompt, completionTokens: completion, totalTokens: total };
}
// Gemini 原生 usageMetadata
const g = data.usageMetadata;
if (g && typeof g === 'object') {
const prompt = num(g.promptTokenCount);
const completion = num(g.candidatesTokenCount);
const total = g.totalTokenCount != null ? num(g.totalTokenCount) : prompt + completion;
if (prompt || completion || total) return { promptTokens: prompt, completionTokens: completion, totalTokens: total };
}
// Ollama 原生回應
if (data.prompt_eval_count != null || data.eval_count != null) {
const prompt = num(data.prompt_eval_count);
const completion = num(data.eval_count);
return { promptTokens: prompt, completionTokens: completion, totalTokens: prompt + completion };
}
// OpenCodetokens 可能位於 data.tokens 或 data.info.tokens
const t = data.tokens || data.info?.tokens || data.data?.info?.tokens;
if (t && typeof t === 'object') {
const prompt = num(t.input ?? t.prompt);
const completion = num(t.output ?? t.completion);
const total = t.total != null ? num(t.total) : prompt + completion;
if (prompt || completion || total) return { promptTokens: prompt, completionTokens: completion, totalTokens: total };
}
return null;
}
/** 記錄一次 LLM 呼叫的 usage(無法解析時仍計一次呼叫,但 token 計 0)。 */
export function recordUsage(data) {
runUsage.calls += 1;
const u = extractUsage(data);
if (u) {
runUsage.promptTokens += u.promptTokens;
runUsage.completionTokens += u.completionTokens;
runUsage.totalTokens += u.totalTokens;
}
return u;
}
/** 取得本次執行至今的 token 累計(複本)。 */
export function getRunUsage() {
return { ...runUsage };
}
/** 重置累計(測試用)。 */
export function resetRunUsage() {
runUsage.calls = 0;
runUsage.promptTokens = 0;
runUsage.completionTokens = 0;
runUsage.totalTokens = 0;
}
/** 最近一次回應的速率配額(rate limit)快照,用來計算「當前視窗剩餘百分比」。 */
const rateLimit = { hasData: false, remaining: null, limit: null, kind: null };
/**
* 將物件第一層的 key 全部轉為小寫並回傳新物件,方便對大小寫不敏感的 HTTP header 取值。
* 不修改傳入物件;僅處理第一層 key。呼叫端須自行確保傳入為物件。
* @param {Object<string, *>} obj 來源物件(通常為 HTTP response headers)。
* @returns {Object<string, *>} key 全小寫的新物件。
*/
function lowerCaseKeys(obj) {
const out = {};
for (const k of Object.keys(obj)) out[k.toLowerCase()] = obj[k];
return out;
}
/**
* 從回應 header 擷取速率配額剩餘量/上限。
* 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropicanthropic-ratelimit-tokens-*),
* 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。
*/
export function recordRateLimit(headers) {
if (!headers || typeof headers !== 'object') return;
const h = lowerCaseKeys(headers);
let remaining = h['x-ratelimit-remaining-tokens'] ?? h['anthropic-ratelimit-tokens-remaining'];
let limit = h['x-ratelimit-limit-tokens'] ?? h['anthropic-ratelimit-tokens-limit'];
let kind = 'tokens';
if (remaining == null || limit == null) {
remaining = h['x-ratelimit-remaining-requests'] ?? h['anthropic-ratelimit-requests-remaining'];
limit = h['x-ratelimit-limit-requests'] ?? h['anthropic-ratelimit-requests-limit'];
kind = 'requests';
}
if (remaining == null || limit == null) return;
rateLimit.hasData = true;
rateLimit.remaining = num(remaining);
rateLimit.limit = num(limit);
rateLimit.kind = kind;
}
/** 取得最近一次的速率配額快照(複本)。 */
export function getRateLimit() {
return { ...rateLimit };
}
/** 重置速率配額快照(測試用)。 */
export function resetRateLimit() {
rateLimit.hasData = false;
rateLimit.remaining = null;
rateLimit.limit = null;
rateLimit.kind = null;
}
/**
* 去除字串結尾的單一斜線(常用於正規化 baseURL 以利串接路徑)。
* 空值會被視為空字串;僅移除最後一個斜線,不處理連續尾斜線。
* @param {*} s 來源字串(通常為 URL)。
* @returns {string} 去除結尾斜線後的字串。
*/
const stripSlash = (s) => String(s || '').replace(/\/$/, '');
/**
* 以解析後的 hostname 精確比對 baseURL 是否為 OpenRouter(僅接受 apex 域名 openrouter.ai)。
* 用於 fetchAccountQuota 的安全守門:防止被偽造或子網域 baseURL 矇騙而外洩 API key。
* 無法解析為合法 URL 時回 false(不丟例外)。
* @param {string} baseURL 待驗證的 base URL。
* @returns {boolean} hostname 恰為 openrouter.ai 時為 true,否則 false。
*/
function isOpenRouterBaseURL(baseURL) {
try {
return new URL(baseURL).hostname.toLowerCase() === 'openrouter.ai';
} catch {
return false;
}
}
/**
* 呼叫 OpenRouter GET /auth/key,以 API key 取得帳號額度(單位 USD credits)。
* remaining 缺漏時以 limit - used 推算;limit 為 null(無上限)時 remaining 亦為 null。
* 本函式不攔截例外;HTTP/網路錯誤會向上拋出,由呼叫端負責降級。
* @param {{apiKey:string, baseURL:string}} cfg 連線設定(API key 與 base URL)。
* @param {function(string, object): Promise<{data:*}>} get HTTP GET 函式(可注入,預設 axios.get)。
* @returns {Promise<{available:true, used:number, limit:number|null, remaining:number|null, currency:'USD', source:'openrouter'}>}
* 額度資訊。
* @throws {Error} HTTP 請求失敗(逾時、非 2xx、網路錯誤等)時拋出。
*/
async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
const resp = await get(`${stripSlash(baseURL)}/auth/key`, {
headers: { Authorization: `Bearer ${apiKey}` },
timeout: 30000,
httpsAgent: getInsecureHttpsAgent(),
});
const d = resp.data?.data || {};
const used = num(d.usage);
const limit = d.limit == null ? null : num(d.limit);
const remaining = d.limit_remaining == null ? (limit == null ? null : limit - used) : num(d.limit_remaining);
return { available: true, used, limit, remaining, currency: 'USD', source: 'openrouter' };
}
/**
* 各平台帳號額度查詢策略。
* 多數官方平台無法僅憑 API key 取得帳號額度(需 org/admin 權限),故誠實回報「無法取得」並附原因;
* 本地/自架服務(ollama/opencode)則回報「不適用」。
*/
const QUOTA_STRATEGIES = {
openai: async (cfg, get) => {
if (isOpenRouterBaseURL(cfg.baseURL)) return fetchOpenRouterQuota(cfg, get);
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };
},
claude: async () => ({ available: false, reason: 'Anthropic 額度需 Admin API 權限,一般 API key 無法取得' }),
gemini: async () => ({ available: false, reason: 'Gemini 額度由 Google Cloud quota 管理,API key 無法直接查詢' }),
amazonq: async () => ({ available: false, reason: 'Amazon Q 額度由 AWS 帳務管理,需 AWS 憑證查詢' }),
ollama: async () => ({ available: false, reason: '本地服務,無帳號額度概念' }),
opencode: async () => ({ available: false, reason: '自架服務,無帳號額度概念' }),
};
/**
* 取得指定平台的帳號額度。任何失敗都降級為 { available: false, reason },不丟例外。
* deps.get 可注入以利測試(預設 axios.get)。
*/
export async function fetchAccountQuota(provider, config = {}, deps = {}) {
const get = deps.get || axios.get;
const strategy = QUOTA_STRATEGIES[provider];
if (!strategy) return { available: false, reason: `未支援 ${provider} 額度查詢` };
const apiKey = Array.isArray(config.apiKeys) ? config.apiKeys[0] : config.apiKey;
try {
return await strategy({ apiKey, baseURL: config.baseURL }, get);
} catch (e) {
warn(`取得 ${provider} 帳號額度失敗(視為無法取得): ${e.message}`);
return { available: false, reason: e.message };
}
}
/**
* 將數字格式化為千分位字串(保留原有小數)。null/undefinedNaN 一律回 '0'。
* @param {number|string|null|undefined} n 待格式化的數值。
* @returns {string} 千分位字串,例如 1234567 → '1,234,567'。
*/
function fmt(n) {
if (n == null || Number.isNaN(Number(n))) return '0';
const [int, frac] = String(Number(n)).split('.');
const withCommas = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
return frac ? `${withCommas}.${frac}` : withCommas;
}
/**
* 將數值格式化為金額字串:有幣別時前綴幣別(如 'USD 1,234'),無幣別則只回千分位數字。
* @param {string} currency 幣別代碼(空字串/falsy 表示無幣別)。
* @param {number|string|null|undefined} n 數值。
* @returns {string} 格式化後的金額字串。
*/
function money(currency, n) {
return currency ? `${currency} ${fmt(n)}` : fmt(n);
}
/**
* 四捨五入到小數一位(用於百分比顯示)。
* 不對非數字防呆;非有限輸入會得到 NaN(呼叫端應先確保為有限數)。
* @param {number|string} n 數值。
* @returns {number} 四捨五入到一位小數的結果。
*/
function round1(n) {
return Math.round(Number(n) * 10) / 10;
}
const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' };
/**
* 計算剩餘百分比(remaining / limit × 100),四捨五入到一位小數。
* 任一參數為 nullundefinedNaNInfinity,或 limit ≤ 0 時回 null
* 以避免算出 Infinity%、NaN%、負百分比或除以零。
* @param {number|null|undefined} remaining 剩餘量。
* @param {number|null|undefined} limit 上限。
* @returns {number|null} 百分比(一位小數);無法計算時為 null。
*/
function calculatePercent(remaining, limit) {
if (remaining == null || limit == null) return null;
const rem = Number(remaining);
const lim = Number(limit);
if (!Number.isFinite(rem) || !Number.isFinite(lim) || lim <= 0) return null;
return round1((rem / lim) * 100);
}
/**
* 計算「剩餘可用百分比」,依優先序擇一:
* 1. 帳號額度(quota 有有效上限)→ 剩餘 credits / 上限;
* 2. 速率配額(rate limit header,有有效上限)→ 當前視窗剩餘 / 上限;
* 上限或剩餘為無效值(null/0/負數/NaN/Infinity)時跳過計算,落到 { percent: null, reason }。
*/
export function resolveRemainingPercent(quota, rate) {
if (quota?.available && quota.limit != null) {
const limit = Number(quota.limit);
const remaining = quota.remaining == null ? limit - num(quota.used) : Number(quota.remaining);
const percent = calculatePercent(remaining, limit);
if (percent != null) {
return { percent, basis: '帳號額度', remaining, limit, unit: quota.currency || '' };
}
}
if (rate?.hasData) {
const percent = calculatePercent(rate.remaining, rate.limit);
if (percent != null) {
const kindLabel = RATE_KIND_LABEL[rate.kind] || rate.kind;
return { percent, basis: `速率配額(當前視窗,${kindLabel}`, remaining: Number(rate.remaining), limit: Number(rate.limit), unit: '' };
}
}
let reason;
if (quota?.available && quota.limit == null) reason = '帳號額度無上限,無法計算百分比';
else if (quota && !quota.available) reason = quota.reason || '平台未提供額度';
else reason = '平台未提供額度或速率配額資訊';
return { percent: null, reason };
}
/**
* 把 resolveRemainingPercent 的結果格式化為一行 Markdown 文字(剩餘可用百分比與明細)。
* 無法計算時輸出帶原因的說明字串。
* @param {{percent:number, basis:string, remaining:number, limit:number, unit:string}|{percent:null, reason:string}} pct
* resolveRemainingPercent 的回傳值。
* @returns {string} 單行 Markdown 字串。
*/
function remainingLine(pct) {
if (pct.percent == null) return `剩餘可用:無法計算百分比(${pct.reason}`;
const detail = `${pct.basis}${money(pct.unit, pct.remaining)} / ${money(pct.unit, pct.limit)}`;
return `剩餘可用 **${pct.percent}%**${detail}`;
}
/** 產生 PR Review 本文用的「AI 助理使用量」Markdown 區塊。 */
export function formatUsageStats(provider, model, usage, quota, rate) {
const pct = resolveRemainingPercent(quota, rate);
const lines = [
'## 🤖 AI 助理使用量',
'',
`**本次審查**${provider} / ${model},共 ${usage.calls} 次呼叫)`,
'',
'| 提示 token | 回應 token | 合計 |',
'| --- | --- | --- |',
`| ${fmt(usage.promptTokens)} | ${fmt(usage.completionTokens)} | ${fmt(usage.totalTokens)} |`,
'',
'**剩餘可用**',
'',
remainingLine(pct),
];
return lines.join('\n');
}
/** 產生單行 log 用的使用量摘要。 */
export function formatUsageStatsLine(provider, model, usage, quota, rate) {
const pct = resolveRemainingPercent(quota, rate);
const tokenPart = `本次 ${provider}/${model}: 提示${usage.promptTokens} + 回應${usage.completionTokens} = ${usage.totalTokens} token${usage.calls} 次呼叫)`;
const pctPart = pct.percent == null
? `;剩餘可用: 無法計算(${pct.reason}`
: `;剩餘可用: ${pct.percent}%${pct.basis} ${money(pct.unit, pct.remaining)}/${money(pct.unit, pct.limit)}`;
return tokenPart + pctPart;
}