Files
ai-code-review/src/llm.js
T

332 lines
14 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 { getLLMConfig, getInsecureHttpsAgent } from './config.js';
import { recordUsage, recordRateLimit } from './usage.js';
import { line } from './log.js';
// 每個 LLM proxy 呼叫(角色分析、補行號等)都是一個獨立 HTTP 請求。預設「不限制」併發(全部同時跑);
// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
// 0 / 未設定 / 非正整數 → 不限制。
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;
/**
* 對 items 並行執行 async fn(保序回傳),加速多個獨立的 LLM 子行程呼叫。
*
* limit 為同時執行上限;`limit <= 0`、非數字或大於項目數時「不限制」(全部並行)。
* fn 需自行處理例外(內部 try/catch);若 fn 未處理而 reject,本函式會立即向外
* 拋出該錯誤(Promise.all fail-fast),但其他已啟動、尚在執行中的併發工作並不會
* 被取消,仍會在背景繼續處理剩餘項目,只是其結果會被捨棄。
*
* @template T, R
* @param {T[]} items - 要處理的項目;非陣列(含 null/undefined)會被視為空陣列,不拋錯。
* @param {number} limit - 同時執行的上限;<=0/非數字表示不限制。
* @param {(item: T, index: number) => Promise<R>} fn - 對每個項目執行的 async 函式。
* @returns {Promise<R[]>} 與 items 對應(同索引)的結果陣列。
* @throws 若任一次 fn 呼叫 reject 且未在內部處理,該錯誤會直接向外傳播。
*/
export async function mapWithConcurrency(items, limit, fn) {
const list = Array.isArray(items) ? items : [];
const results = new Array(list.length);
if (list.length === 0) return results;
const n = Number(limit);
const workers = (!Number.isFinite(n) || n <= 0) ? list.length : Math.min(n, list.length);
let cursor = 0;
/**
* 內部 worker:從共用游標依序搶下一個索引,呼叫 `fn` 後把結果寫回對應位置。
*
* @returns {Promise<void>} 無回傳值。
*/
async function run() {
while (cursor < list.length) {
const i = cursor++;
results[i] = await fn(list[i], i);
}
}
await Promise.all(Array.from({ length: workers }, run));
return results;
}
/**
* 將 system prompt 與 user content 合併成單一文字,作為送往 CLIProxyAPI 的
* 「使用者訊息」內容。
*
* 注意:此函式回傳的合併文字,會被 chat() 整段放入 HTTP request 的 user role
* 內容;實際送出的 HTTP system role 訊息是固定的通用指示(見 runProxyAPI),
* 並非這裡傳入的 systemPrompt——systemPrompt 是以 `<system>` 標籤形式內嵌在
* user 內容中,而非透過 API 的 system role 傳遞。
*
* @param {string} systemPrompt - 系統提示詞內容,會被包在 `<system>...</system>`
* 標籤內;`null`/`undefined` 會被視為空字串。
* @param {string} userContent - 使用者輸入內容,會被包在 `<user>...</user>`
* 標籤內;`null`/`undefined` 會被視為空字串。
* @returns {string} 合併後、以換行分隔的完整 prompt 文字。
*/
function buildPrompt(systemPrompt, userContent) {
return [
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
'',
'<system>',
systemPrompt,
'</system>',
'',
'<user>',
userContent,
'</user>',
].join('\n');
}
/**
* 從 proxy API 錯誤輸出中抽出「真正有意義的錯誤」。
*
* 直接取前段很容易被 HTML / JSON 包裝或回顯雜訊洗掉,因此改為:先抽出看起來像
* 錯誤的行;抽不到再退取尾段。
*
* @param {string} raw - HTTP 錯誤原始內容(response body、stderr 或 stdout)。
* @param {number} [limit=1000] - 回傳字串長度上限。
* @returns {string} 最能說明失敗原因的片段。
*/
export function extractMeaningfulError(raw, limit = 1000) {
const text = String(raw || '').trim();
const errorLines = text
.split('\n')
.filter(l => /\bERROR\b|error:|unauthorized|invalidated|revoked|forbidden|\b40[13]\b|\b429\b|rate.?limit|quota|insufficient|temporarily unavailable/i.test(l));
const picked = (errorLines.length ? errorLines.join('\n') : text).trim();
return picked.length > limit ? picked.slice(-limit) : picked;
}
/**
* 將 HTTP 例外整理成較精簡的錯誤摘要,格式為 `"HTTP <status> <訊息>"`
* (無 status 時只有訊息)。
*
* 依序嘗試:`response.data`(字串或物件的 error.message/message/error 欄位)→
* `stderr` → `stdout` → `e.message` → `String(e)`,取第一個非空來源後交給
* extractMeaningfulError 濃縮成精簡訊息,再與 HTTP 狀態碼(若有)合併。
*
* @param {*} e - 被拋出的錯誤物件,預期含 `response.data`/`response.status`/
* `stderr`/`stdout`/`message` 其中之一或多個。
* @returns {string} 精簡後的錯誤訊息;兩者皆空則回傳空字串。
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
* @remarks 容錯僅涵蓋「e 是物件但欄位缺失或型態不符」的情況;若 e 本身為
* `null`/`undefined`,存取 `e.stderr`/`e.stdout`/`e.message` 會直接拋出
* TypeError,並非完全的保守容錯(需人工確認是否要補上 optional chaining 修正)。
*/
function summarizeApiError(e) {
const responseData = e?.response?.data;
const responseText = typeof responseData === 'string'
? responseData
: responseData?.error?.message
|| responseData?.message
|| responseData?.error
|| '';
const stderr = String(e?.stderr || '').trim();
const stdout = String(e?.stdout || '').trim();
const status = e?.response?.status ? `HTTP ${e.response.status}` : '';
const message = extractMeaningfulError(responseText || stderr || stdout || e?.message || String(e));
return [status, message].filter(Boolean).join(' ').trim();
}
/**
* 透過 CLIProxyAPI 執行一次對話 HTTP 請求,回傳 API 的原始回應資料(物件),
* 並記錄回應 header 中的速率配額資訊。
*
* 僅送出一次請求,不含任何重試邏輯——失敗(逾時、網路錯誤、非 2xx 狀態碼)時
* 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用
* `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。
*
* @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定;
* 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。
* @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容;
* HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `<system>` 內容無關。
* @returns {Promise<any>} API 回應的原始資料物件(`resp.data`),並非純文字;
* 純文字需由呼叫端自行從 `data.choices[0].message.content` 等欄位擷取。
* @throws 當 HTTP 請求失敗(逾時、網路錯誤、非 2xx 狀態碼)時,`axios` 拋出的
* 例外會原樣向外傳播,本函式不攔截、不重試。
* @remarks 逾時與輸出上限由環境變數 `AI_ASSISTANT_TIMEOUT_MS`/`AI_ASSISTANT_MAX_BUFFER`
* 控制,預設值為 15 分鐘/20 MB。
* @remarks 使用 `getInsecureHttpsAgent()`(停用 TLS 憑證驗證),適用內部自簽憑證環境。
*/
async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
const root = String(baseURL || '').trim().replace(/\/$/, '');
const apiKey = Array.isArray(apiKeys) ? apiKeys[0] : '';
const resp = await axios.post(
`${root}/v1/chat/completions`,
{
messages: [
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
{ role: 'user', content: prompt },
],
temperature: 0,
stream: false,
...(model ? { model } : {}),
},
{
timeout,
maxBodyLength: maxBuffer,
maxContentLength: maxBuffer,
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
httpsAgent: getInsecureHttpsAgent(),
},
);
recordRateLimit(resp.headers || {});
return resp.data;
}
/**
* 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。
*
* 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次
* usage 呼叫並回傳內容。**不含任何重試邏輯**——無論是設定缺失、底層 HTTP 請求
* 失敗,或回應內容為空,都是失敗一次即向外拋出(重新包裝為新的 Error,只保留
* 摘要後訊息),不會自動重試或切換金鑰/provider。呼叫前後皆會透過 line() 記錄
* 一行 log(成功記啟動資訊,失敗記錯誤摘要)。
*
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @returns {Promise<string>} 模型回應的純文字內容。
* @throws {Error} 當未偵測到可用 CLIProxyAPI 設定、底層 API 呼叫失敗,或回應
* 缺少可用文字內容時。
*/
export async function chat(systemPrompt, userContent) {
const cfg = getLLMConfig();
const { provider, baseURL, model, modelError } = cfg;
if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API');
if (modelError) throw new Error(modelError);
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`);
try {
const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent));
recordUsage(data);
const content = data?.choices?.[0]?.message?.content
?? data?.choices?.[0]?.text
?? data?.output_text
?? data?.content
?? '';
const text = String(content).trim();
if (!text) throw new Error('CLIProxyAPI 回應缺少文字內容');
return text;
} catch (e) {
const message = summarizeApiError(e);
line(`[LLM] ${provider} API 呼叫失敗: ${message}`);
throw new Error(message);
}
}
/**
* 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。
*
* 先呼叫 {@link chat} 取得文字回應,再經 {@link extractJSONText} 抽出 JSON 片段後
* 以 JSON.parse 解析。**僅 JSON 解析失敗時容錯**(記錄錯誤並回傳空陣列 `[]`,不
* 向外拋錯);若 `chat()` 本身失敗(例如未偵測到可用 CLIProxyAPI 設定、API 呼叫
* 失敗,或回應缺少文字內容),該例外不會被本函式攔截,會直接向外拋出。
*
* @param {string} systemPrompt - 系統提示詞。
* @param {string} userContent - 使用者輸入內容。
* @returns {Promise<any>} 解析後的 JSON 值;僅當 JSON 解析失敗時回傳空陣列 `[]`。
* @throws {Error} 當底層 {@link chat} 呼叫失敗時(設定缺失、API 錯誤、回應無文字
* 內容等),例外會原樣向外傳播。
*/
export async function chatJSON(systemPrompt, userContent) {
const text = await chat(systemPrompt, userContent);
try {
return JSON.parse(extractJSONText(text));
} catch (e) {
line(`[LLM] JSON 解析失敗: ${e.message}`);
return [];
}
}
/**
* 去除文字外層的 Markdown code fence(```),用於清理被 code block 包裹的輸出。
*
* 會 trim、移除開頭 fence(含可選語言標籤與換行)與結尾 fence,再 trim。
* 對非字串輸入會先以 `String()` 轉換;無 fence 時回傳 trim 後原文。
*
* @param {*} text - 待清理的內容(會被轉為字串)。
* @returns {string} 去除外層 fence 並 trim 後的字串。
*/
function stripOuterFence(text) {
return String(text)
.trim()
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
.replace(/```$/, '')
.trim();
}
/**
* 從指定索引起,以括號平衡方式擷取一段完整配對的 JSON 子字串。
*
* 依起始字元判定為物件(`{}`)或陣列(`[]`),逐字元計數巢狀深度,
* 並正確略過字串字面值與其中的跳脫字元,深度歸零時回傳完整片段。
*
* @param {*} text - 來源內容(會被轉為字串)。
* @param {number} startIndex - 起始掃描索引,應指向 `{` 或 `[`。
* @returns {string|null} 配對完整的 JSON 子字串;找不到配對時回傳 `null`。
*/
export function extractBalancedJSON(text, startIndex) {
const source = String(text);
const open = source[startIndex];
const close = open === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const ch = source[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return source.slice(startIndex, i + 1);
}
}
return null;
}
/**
* 從可能夾雜雜訊或被 code fence 包裹的文字中,盡力抽出可被 JSON.parse 解析的片段。
*
* 先去除外層 fence;若整段即為合法 JSON 直接回傳;否則由左至右尋找每個
* `{`/`[` 起點,以括號平衡擷取候選片段並試解析,回傳第一個成功者;
* 全數失敗則回傳去 fence 後的原文(仍可能非合法 JSON,交由呼叫端再處理)。
*
* @param {*} text - 可能含有 JSON 的原始內容(會被轉為字串)。
* @returns {string} 最可能為合法 JSON 的字串片段,或去 fence 後的原文。
*/
export function extractJSONText(text) {
const stripped = stripOuterFence(text);
try {
JSON.parse(stripped);
return stripped;
} catch {}
for (let i = 0; i < stripped.length; i++) {
if (stripped[i] !== '[' && stripped[i] !== '{') continue;
const candidate = extractBalancedJSON(stripped, i);
if (!candidate) continue;
try {
JSON.parse(candidate);
return candidate;
} catch {}
}
return stripped;
}