import axios from 'axios'; 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 Responses(input/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 }; } // OpenCode(tokens 可能位於 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} obj 來源物件(通常為 HTTP response headers)。 * @returns {Object} 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)與 Anthropic(anthropic-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, }); 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 無法取得' }), antigravity: async () => ({ available: false, reason: 'Antigravity 額度由 Google 帳務/方案管理,CLI 無法直接查詢' }), 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/undefined/NaN 一律回 '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),四捨五入到一位小數。 * 任一參數為 null/undefined/NaN/Infinity,或 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; }