feat(ai-review 對話收斂): 讀 PR review 留言判斷解決狀態並收斂 findings #42
@@ -84,12 +84,14 @@ export function formatFindingsStatsLine(findings) {
|
|||||||
return `舊: ${row(oldFindings)};新: ${row(newFindings)}`;
|
return `舊: ${row(oldFindings)};新: ${row(newFindings)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReviewSummary(findings) {
|
function buildReviewSummary(findings, usageSection = '') {
|
||||||
return [
|
const parts = [
|
||||||
'## AI Code Review 統計',
|
'## AI Code Review 統計',
|
||||||
'',
|
'',
|
||||||
formatFindingsStats(findings),
|
formatFindingsStats(findings),
|
||||||
].join('\n');
|
];
|
||||||
|
if (usageSection) parts.push('', usageSection);
|
||||||
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toReviewComment(f) {
|
function toReviewComment(f) {
|
||||||
@@ -112,10 +114,11 @@ export async function postFindingsReview(findings, deps = {}) {
|
|||||||
postReview = postPullReview,
|
postReview = postPullReview,
|
||||||
summaryFindings = findings,
|
summaryFindings = findings,
|
||||||
commentFindings = findings,
|
commentFindings = findings,
|
||||||
|
usageSection = '',
|
||||||
} = deps;
|
} = deps;
|
||||||
const sortedComments = [...commentFindings].sort(bySeverity);
|
const sortedComments = [...commentFindings].sort(bySeverity);
|
||||||
const comments = sortedComments.map(toReviewComment).filter(Boolean);
|
const comments = sortedComments.map(toReviewComment).filter(Boolean);
|
||||||
const body = buildReviewSummary(summaryFindings);
|
const body = buildReviewSummary(summaryFindings, usageSection);
|
||||||
await postReview({ body, comments });
|
await postReview({ body, comments });
|
||||||
ok(`review 發布: summary=${summaryFindings.length} total=${sortedComments.length} commentable=${comments.length}`);
|
ok(`review 發布: summary=${summaryFindings.length} total=${sortedComments.length} commentable=${comments.length}`);
|
||||||
line(`review summary 統計: ${formatFindingsStatsLine(summaryFindings)}`);
|
line(`review summary 統計: ${formatFindingsStatsLine(summaryFindings)}`);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js';
|
||||||
|
import { recordUsage, recordRateLimit } from './usage.js';
|
||||||
import { line, error } from './log.js';
|
import { line, error } from './log.js';
|
||||||
|
|
||||||
function isOpenAIGpt55(provider, model) {
|
function isOpenAIGpt55(provider, model) {
|
||||||
@@ -81,7 +82,7 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers)
|
|||||||
},
|
},
|
||||||
opencodeAxiosOptions(headers)
|
opencodeAxiosOptions(headers)
|
||||||
);
|
);
|
||||||
return extractOpenCodeContent(resp.data);
|
return { content: extractOpenCodeContent(resp.data), data: resp.data };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
@@ -99,13 +100,17 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
try {
|
try {
|
||||||
if (provider === 'opencode') {
|
if (provider === 'opencode') {
|
||||||
applyOpenCodeAuth(headers);
|
applyOpenCodeAuth(headers);
|
||||||
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
|
const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
|
||||||
|
recordUsage(data);
|
||||||
|
return content;
|
||||||
}
|
}
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
chatEndpoint(baseURL, provider, model),
|
chatEndpoint(baseURL, provider, model),
|
||||||
chatPayload(provider, model, systemPrompt, userContent),
|
chatPayload(provider, model, systemPrompt, userContent),
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
|
recordUsage(resp.data);
|
||||||
|
recordRateLimit(resp.headers);
|
||||||
return extractContent(provider, model, resp.data);
|
return extractContent(provider, model, resp.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getPRDiff, postComment, getCommitMessageBySha, getBotReviewOutcome, sho
|
|||||||
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
import { analyzeWithRole, loadOldFindings, mergeFindings, sortByLevel, deduplicateWithAI, loadExclusions, applyExclusions, filterFalsePositivesWithAI } from './findings.js';
|
||||||
import { reconcileConversations, dropResolvedFindings, addCarriedFindings } from './resolve.js';
|
import { reconcileConversations, dropResolvedFindings, addCarriedFindings } from './resolve.js';
|
||||||
import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js';
|
import { saveFindings, postFindingsReview, formatFindingsStatsLine } from './comments.js';
|
||||||
|
import { getRunUsage, getRateLimit, fetchAccountQuota, formatUsageStats, formatUsageStatsLine } from './usage.js';
|
||||||
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
import { cloneRepo, commitAndPush, getRepoState } from './git.js';
|
||||||
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
import { validateJSONArrayFile, ensureJSONArrayFileExists } from './json.js';
|
||||||
import { runPreflight } from './preflight.js';
|
import { runPreflight } from './preflight.js';
|
||||||
@@ -53,7 +54,7 @@ async function main() {
|
|||||||
warn(`Step2 對話收斂失敗(繼續執行): ${e.message}`);
|
warn(`Step2 對話收斂失敗(繼續執行): ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { provider, baseURL, model } = getLLMConfig();
|
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
error('未設定任何 LLM API Key,請檢查 action inputs');
|
error('未設定任何 LLM API Key,請檢查 action inputs');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -137,6 +138,14 @@ async function main() {
|
|||||||
step('Step6', 'Findings 寫入與 Review 發布');
|
step('Step6', 'Findings 寫入與 Review 發布');
|
||||||
const reviewDir = repoDir || WORKSPACE;
|
const reviewDir = repoDir || WORKSPACE;
|
||||||
saveFindings(WORKSPACE, filtered, reviewDir);
|
saveFindings(WORKSPACE, filtered, reviewDir);
|
||||||
|
|
||||||
|
// 蒐集 AI 助理使用量:本次 token 消耗 + 剩餘可用百分比(帳號額度優先,否則用回應 header 的速率配額;皆失敗時降級為「無法計算」,不中斷流程)
|
||||||
|
const runUsage = getRunUsage();
|
||||||
|
const quota = await fetchAccountQuota(provider, { apiKeys, baseURL });
|
||||||
|
const rate = getRateLimit();
|
||||||
|
const usageSection = formatUsageStats(provider, model, runUsage, quota, rate);
|
||||||
|
line(`使用量統計: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logFindingsStats('Step6 儲存 findings 統計', filtered);
|
logFindingsStats('Step6 儲存 findings 統計', filtered);
|
||||||
logFindingsStats('Step6 review summary 統計', filtered);
|
logFindingsStats('Step6 review summary 統計', filtered);
|
||||||
@@ -144,6 +153,7 @@ async function main() {
|
|||||||
await postFindingsReview(filtered, {
|
await postFindingsReview(filtered, {
|
||||||
summaryFindings: filtered,
|
summaryFindings: filtered,
|
||||||
commentFindings: filtered,
|
commentFindings: filtered,
|
||||||
|
usageSection,
|
||||||
});
|
});
|
||||||
ok('Step6 完成');
|
ok('Step6 完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { warn } from './log.js';
|
||||||
|
|
||||||
|
/** 本次執行的 token 累計(跨所有 LLM 呼叫)。 */
|
||||||
|
const runUsage = { calls: 0, promptTokens: 0, completionTokens: 0, totalTokens: 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。
|
||||||
|
admin marked this conversation as resolved
|
|||||||
|
* 回應中沒有任何可辨識的 usage 時回傳 null。
|
||||||
|
*/
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:在 `extractUsage` 中,對於 `data.usage` 的屬性存取直接使用 `num(...)`,這在 `data.usage` 如果是 `null` 或其他 falsy 值但被 `typeof` 判斷通過時(JS 的 `typeof null === 'object'`),會導致錯誤。
**建議**:應明確檢查 `u` 是否為嚴格的 `object` 且非 `null`,例如 `if (u && typeof u === 'object' && !Array.isArray(u))`。
|
|||||||
|
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 = 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 = 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 = 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 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 從回應 header 擷取速率配額剩餘量/上限。
|
||||||
|
* 支援 OpenAI 相容(x-ratelimit-*-tokens)與 Anthropic(anthropic-ratelimit-tokens-*),
|
||||||
|
* 兩者皆缺時退而採用 requests 維度。記錄「最近一次」的數值(即最新的視窗狀態)。
|
||||||
|
*/
|
||||||
|
export function recordRateLimit(headers) {
|
||||||
|
if (!headers || typeof headers !== 'object') return;
|
||||||
|
const h = {};
|
||||||
|
for (const k of Object.keys(headers)) h[k.toLowerCase()] = headers[k];
|
||||||
|
|
||||||
|
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;
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Assassin
**問題**:函數 `isOpenRouterBaseURL` 僅使用 `new URL(baseURL).hostname.endsWith('.openrouter.ai')` 來判斷,這極易受到偽造域名攻擊(如 `openrouter.ai.malicious.com`),導致惡意主機被信任為 OpenRouter,進而洩漏 API Key。
**建議**:應修改為嚴格比對,例如 `hostname === 'openrouter.ai'`,且必須包含 protocol 檢查(如 `https`),並建議採用白名單機制而非簡單的 `endsWith`。
|
|||||||
|
rateLimit.remaining = num(remaining);
|
||||||
|
rateLimit.limit = num(limit);
|
||||||
|
rateLimit.kind = kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取得最近一次的速率配額快照(複本)。 */
|
||||||
|
export function getRateLimit() {
|
||||||
|
return { ...rateLimit };
|
||||||
|
}
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Mage
**問題**:在 `recordRateLimit` 中,處理 Header 時將所有 Key 轉為小寫並存入物件 `h`,如果原始 Header 中存在多個相同名稱但不同大小寫的 Header(雖然 HTTP 標準規定 Key 不區分大小寫,但某些實作可能會有不一致),可能會造成覆蓋。
**建議**:雖然 HTTP 規範不區分,但為了安全起見,應先確認環境使用的 axios 版本對 Header 的處理方式,或確保在轉換前沒有遺漏必要資訊。
|
|||||||
|
|
||||||
|
/** 重置速率配額快照(測試用)。 */
|
||||||
|
export function resetRateLimit() {
|
||||||
|
rateLimit.hasData = false;
|
||||||
|
rateLimit.remaining = null;
|
||||||
|
rateLimit.limit = null;
|
||||||
|
rateLimit.kind = null;
|
||||||
|
}
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Bard
**問題**:`recordRateLimit` 函式中對於 headers 的處理,將所有 key 轉換為小寫後檢查,這雖然兼容了多種平台規範,但處理邏輯稍顯冗長,降低了程式碼的流暢度。
**建議**:建議提取一個專門處理 header 正規化的工具函式,使主邏輯更簡潔。
|
|||||||
|
|
||||||
|
const stripSlash = (s) => String(s || '').replace(/\/$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenRouter:以 API key 呼叫 GET /auth/key 取得額度(可靠)。
|
||||||
|
* 回傳金額單位為 USD credits。
|
||||||
|
*/
|
||||||
|
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)則回報「不適用」。
|
||||||
|
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Rogue
**問題**:在 `recordRateLimit` 中頻繁呼叫 `lowerCaseKeys`,這會對每個請求的 headers 進行複製與轉換,增加記憶體分配開銷。
**建議**:建議直接存取 headers 時改用不區分大小寫的存取函式,避免複製整個物件。
|
|||||||
|
*/
|
||||||
|
const QUOTA_STRATEGIES = {
|
||||||
|
openai: async (cfg, get) => {
|
||||||
|
if (/openrouter\.ai/i.test(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];
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:在 `fetchAccountQuota` 中,使用 `axios.get` 直接請求傳入的 `baseURL`。如果 `baseURL` 是由設定檔動態讀取,攻擊者可能會透過修改設定檔將其導向惡意伺服器(SSRF),進而竊取 API Key 或發送偽造請求。
**建議**:應對 `baseURL` 進行嚴格的白名單校驗,確保其僅能連線至合法的 API 提供商域名。不要信任外部設定檔中的 URL。
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Assassin
**問題**:在 `fetchAccountQuota` 中,使用 `axios.get` 直接請求傳入的 `baseURL`。如果 `baseURL` 是由設定檔動態讀取,攻擊者可能會透過修改設定檔將其導向惡意伺服器(SSRF),進而竊取 API Key 或發送偽造請求。
**建議**:應對 `baseURL` 進行嚴格的白名單校驗,確保其僅能連線至合法的 API 提供商域名。不要信任外部設定檔中的 URL。
|
|||||||
|
if (!strategy) return { available: false, reason: `未支援 ${provider} 額度查詢` };
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🔵 建議 **嚴重等級**:🔵 建議
**審查員**:Bard
**問題**:`fetchAccountQuota` 中的 `QUOTA_STRATEGIES` 物件定義龐大,將所有平台的策略硬編碼在此處,未來若新增更多 LLM 供應商,此處將變得難以維護。
**建議**:建議將各供應商的額度查詢策略抽離至獨立的檔案或策略模式處理,以保持 `usage.js` 的整潔。
|
|||||||
|
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 };
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:`fetchAccountQuota` 策略在處理 API key 時,假設 `apiKeys` 陣列存在並取第一個,若傳入的 `config.apiKeys` 為空陣列或 undefined,缺乏明確的防禦與測試。
**建議**:補測試案例,模擬 `config.apiKeys` 為空或無效的情境,確認系統降級行為是否符合預期。
|
|||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🔴 嚴重 **嚴重等級**:🔴 嚴重
**審查員**:Mage
**問題**:在 `fetchAccountQuota` 中,呼叫 `strategy` 時傳入的 `config` 物件,如果在特定 `strategy` 中被意外修改,會影響到全域的 config 狀態,且傳入的 `get` 函數來源若未被嚴格隔離,可能存在潛在的請求偽造風險。
**建議**:傳入 `strategy` 的 config 應進行淺拷貝(shallow copy),確保不可變性。
|
|||||||
|
/** 千分位整數/小數格式。 */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(currency, n) {
|
||||||
|
return currency ? `${currency} ${fmt(n)}` : fmt(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(n) {
|
||||||
|
return Math.round(Number(n) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RATE_KIND_LABEL = { tokens: 'token', requests: '次數' };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 計算「剩餘可用百分比」,依優先序擇一:
|
||||||
|
* 1. 帳號額度(quota 有上限)→ 剩餘 credits / 上限;
|
||||||
|
* 2. 速率配額(rate limit header)→ 當前視窗剩餘 / 上限;
|
||||||
|
* 皆無法取得時回傳 { percent: null, reason }。
|
||||||
|
*/
|
||||||
|
export function resolveRemainingPercent(quota, rate) {
|
||||||
|
if (quota?.available && quota.limit != null && Number(quota.limit) > 0) {
|
||||||
|
const limit = Number(quota.limit);
|
||||||
|
const remaining = quota.remaining == null ? limit - num(quota.used) : Number(quota.remaining);
|
||||||
|
return { percent: round1((remaining / limit) * 100), basis: '帳號額度', remaining, limit, unit: quota.currency || '' };
|
||||||
|
}
|
||||||
|
if (rate?.hasData && Number(rate.limit) > 0) {
|
||||||
|
const limit = Number(rate.limit);
|
||||||
|
const remaining = Number(rate.remaining);
|
||||||
|
const kindLabel = RATE_KIND_LABEL[rate.kind] || rate.kind;
|
||||||
|
return { percent: round1((remaining / limit) * 100), basis: `速率配額(當前視窗,${kindLabel})`, remaining, limit, unit: '' };
|
||||||
|
admin marked this conversation as resolved
admin
commented
嚴重等級:🟡 警告 **嚴重等級**:🟡 警告
**審查員**:Maya
**問題**:`resolveRemainingPercent` 函數負責處理額度計算,但針對 `quota.limit` 為 0 的情況缺乏顯式處理,可能會導致除以零或錯誤的百分比計算結果。
**建議**:補測試案例,模擬 `quota.limit` 為 0 的情境,確認系統是否正確處理或返回錯誤訊息,避免計算偏差。
|
|||||||
|
}
|
||||||
|
let reason;
|
||||||
|
if (quota?.available && quota.limit == null) reason = '帳號額度無上限,無法計算百分比';
|
||||||
|
else if (quota && !quota.available) reason = quota.reason || '平台未提供額度';
|
||||||
|
else reason = '平台未提供額度或速率配額資訊';
|
||||||
|
return { percent: null, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
嚴重等級:🟡 警告
審查員:Mage
問題:extractUsage 中對於 OpenAI 相容格式的處理:
const total = num(u.total_tokens) || prompt + completion;。如果 API 回傳了total_tokens: 0(雖然極少見但非零可能),這裡的邏輯會觸發prompt + completion的計算,導致數值不準確。建議:應明確判斷
u.total_tokens != null而非僅檢查其 truthiness,以確保在 API 明確回傳 0 時能正確讀取。