refactor: switch ai-code-review to CLIProxyAPI
This commit is contained in:
+75
-109
@@ -1,12 +1,9 @@
|
||||
import * as childProcess from 'child_process';
|
||||
import { mkdtemp, writeFile, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { getLLMConfig } from './config.js';
|
||||
import { recordUsage } from './usage.js';
|
||||
import axios from 'axios';
|
||||
import { getLLMConfig, getInsecureHttpsAgent } from './config.js';
|
||||
import { recordUsage, recordRateLimit } from './usage.js';
|
||||
import { line } from './log.js';
|
||||
|
||||
// 每個 LLM CLI 呼叫(角色分析、補行號等)都是一個獨立子行程。預設「不限制」併發(全部同時跑);
|
||||
// 每個 LLM proxy 呼叫(角色分析、補行號等)都是一個獨立 HTTP 請求。預設「不限制」併發(全部同時跑);
|
||||
// 若機器資源不足或撞到提供者限流,可用 AI_ASSISTANT_CONCURRENCY 設一個正整數當上限。
|
||||
// 0 / 未設定 / 非正整數 → 不限制。
|
||||
export const LLM_CONCURRENCY = Number(process.env.AI_ASSISTANT_CONCURRENCY) || 0;
|
||||
@@ -40,7 +37,7 @@ export async function mapWithConcurrency(items, limit, fn) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 將既有 system/user prompt 合併成一次 CLI 呼叫用的輸入。
|
||||
* 將既有 system/user prompt 合併成一次 HTTP 呼叫用的輸入。
|
||||
*/
|
||||
function buildPrompt(systemPrompt, userContent) {
|
||||
return [
|
||||
@@ -57,39 +54,12 @@ function buildPrompt(systemPrompt, userContent) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 依不同 AI provider 產生 CLI 參數。
|
||||
* 從 proxy API 錯誤輸出中抽出「真正有意義的錯誤」。
|
||||
*
|
||||
* @param {*} provider - AI provider 名稱。
|
||||
* @param {*} model - 模型名稱。
|
||||
* @param {*} promptFile - prompt 檔路徑,供 `opencode` 使用。
|
||||
* @param {*} prompt - 直接傳給 CLI 的 prompt 文字,供部分 provider 使用。
|
||||
* @remarks 適合把不同 CLI 的參數差異集中管理。
|
||||
* @remarks 目前支援的 provider 名稱是硬編碼的,新增 provider 時需人工確認是否同步更新所有呼叫端。
|
||||
*/
|
||||
function cliArgs({ provider, model, promptFile = null, prompt = null }) {
|
||||
if (provider === 'codex') {
|
||||
return ['exec', '--model', model, '--sandbox', 'read-only', '--skip-git-repo-check', '-'];
|
||||
}
|
||||
if (provider === 'claude') {
|
||||
return ['--print', '--model', model, '--permission-mode', 'dontAsk', '--no-session-persistence'];
|
||||
}
|
||||
if (provider === 'antigravity') {
|
||||
return ['-p', prompt, '--model', model];
|
||||
}
|
||||
if (provider === 'opencode') {
|
||||
return ['run', '--model', model, '--format', 'default', '--file', promptFile, '請依附件 prompt.md 的完整內容執行,並只輸出要求的最終結果。'];
|
||||
}
|
||||
throw new Error(`不支援的 AI 助理 CLI: ${provider}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 從 CLI 輸出中抽出「真正有意義的錯誤」。
|
||||
* 直接取前段很容易被 HTML / JSON 包裝或回顯雜訊洗掉,因此改為:先抽出看起來像
|
||||
* 錯誤的行;抽不到再退取尾段。
|
||||
*
|
||||
* 像 codex 這類 CLI 會先印出一大段 banner(workdir/model/...)與回顯的 prompt,
|
||||
* 真正的失敗原因(例如 401、token 失效、額度不足)通常落在**尾端**。直接取前段
|
||||
* 會被 banner/prompt 洗掉,因此改為:先抽出看起來像錯誤的行;抽不到再退取尾段。
|
||||
*
|
||||
* @param {string} raw - CLI 的原始輸出(stderr 或 stdout)。
|
||||
* @param {string} raw - HTTP 錯誤原始內容(response body、stderr 或 stdout)。
|
||||
* @param {number} [limit=1000] - 回傳字串長度上限。
|
||||
* @returns {string} 最能說明失敗原因的片段。
|
||||
*/
|
||||
@@ -97,116 +67,112 @@ 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|rate.?limit|quota|insufficient/i.test(l));
|
||||
.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 將 CLI 例外整理成較精簡的錯誤摘要。
|
||||
* 將 HTTP 例外整理成較精簡的錯誤摘要。
|
||||
*
|
||||
* @param {*} e - 被拋出的錯誤物件,可能含 `stderr`、`stdout`、`message`。
|
||||
* @remarks 適合在 log 與錯誤重新拋出前先整理訊息。
|
||||
* @remarks 若錯誤物件結構和預期不同,仍會退回字串化處理,屬保守容錯。
|
||||
*/
|
||||
function summarizeCliError(e) {
|
||||
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();
|
||||
return extractMeaningfulError(stderr || stdout || e.message || String(e));
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 執行 AI 助理 CLI 並回傳純文字結果。
|
||||
* 透過 CLIProxyAPI 執行一次對話並回傳純文字結果。
|
||||
*
|
||||
* @param {*} provider - CLI provider 名稱。
|
||||
* @param {*} command - 實際可執行指令。
|
||||
* @param {*} model - 要使用的模型名稱。
|
||||
* @param {*} prompt - 送給 CLI 的完整 prompt 內容。
|
||||
* @remarks 適合用在需呼叫外部 AI CLI 的情境。
|
||||
* @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定。
|
||||
* @param {string} prompt - 送給 API 的完整 prompt 內容。
|
||||
* @remarks 適合用在需呼叫外部 AI API 的情境。
|
||||
* @remarks 逾時與輸出上限由環境變數控制,預設值是保守設定。
|
||||
* @remarks 若子行程回傳非 0,錯誤訊息會由上層摘要處理。
|
||||
* @remarks 若 HTTP 回傳非 2xx,錯誤訊息會由上層摘要處理。
|
||||
*/
|
||||
async function runAssistantCLI({ provider, command, model }, prompt) {
|
||||
let tempDir = null;
|
||||
let promptFile = null;
|
||||
if (provider === 'opencode') {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ai-review-prompt-'));
|
||||
promptFile = join(tempDir, 'prompt.md');
|
||||
await writeFile(promptFile, prompt);
|
||||
}
|
||||
const args = cliArgs({ provider, model, promptFile, prompt });
|
||||
const maxBuffer = Number(process.env.AI_ASSISTANT_MAX_BUFFER || 20 * 1024 * 1024);
|
||||
async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) {
|
||||
const timeout = Number(process.env.AI_ASSISTANT_TIMEOUT_MS || 15 * 60 * 1000);
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = childProcess.spawn(command, args, { env: process.env, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`${provider} CLI 逾時 (${timeout}ms)`));
|
||||
}, timeout);
|
||||
const append = (kind, chunk) => {
|
||||
if (kind === 'stdout') stdout += chunk;
|
||||
else stderr += chunk;
|
||||
if (stdout.length + stderr.length > maxBuffer) {
|
||||
settled = true;
|
||||
child.kill('SIGTERM');
|
||||
reject(new Error(`${provider} CLI 輸出超過 ${maxBuffer} bytes`));
|
||||
}
|
||||
};
|
||||
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`,
|
||||
{
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature: 0,
|
||||
stream: false,
|
||||
},
|
||||
{
|
||||
timeout,
|
||||
maxBodyLength: maxBuffer,
|
||||
maxContentLength: maxBuffer,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
||||
},
|
||||
httpsAgent: getInsecureHttpsAgent(),
|
||||
},
|
||||
);
|
||||
|
||||
child.stdout.setEncoding('utf8');
|
||||
child.stderr.setEncoding('utf8');
|
||||
child.stdout.on('data', chunk => append('stdout', chunk));
|
||||
child.stderr.on('data', chunk => append('stderr', chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code, signal) => {
|
||||
clearTimeout(timer);
|
||||
if (settled) return;
|
||||
if (code === 0) resolve(stdout.trim());
|
||||
else reject(Object.assign(new Error(`${provider} CLI exited with ${code ?? signal}`), { stdout, stderr }));
|
||||
});
|
||||
child.stdin.end(provider === 'opencode' || provider === 'antigravity' ? '' : prompt);
|
||||
});
|
||||
} finally {
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
recordRateLimit(resp.headers || {});
|
||||
return resp.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 對目前環境可用的 AI 助理 CLI 送出一次對話請求並回傳純文字回應。
|
||||
* 對目前環境可用的 CLIProxyAPI 送出一次對話請求並回傳純文字回應。
|
||||
*
|
||||
* 從設定取得 provider/command/model;未偵測到 CLI 時拋錯。成功時記錄一次
|
||||
* usage 呼叫(CLI 通常不回傳 token 明細,因此 token 可能為 0)並回傳內容。
|
||||
* 從設定取得 provider/baseURL/model;未偵測到 proxy 時拋錯。成功時記錄一次
|
||||
* usage 呼叫並回傳內容。
|
||||
*
|
||||
* @param {string} systemPrompt - 系統提示詞。
|
||||
* @param {string} userContent - 使用者輸入內容。
|
||||
* @returns {Promise<string>} 模型回應的純文字內容。
|
||||
* @throws {Error} 當未偵測到可用 AI 助理 CLI,或 CLI 呼叫失敗時。
|
||||
* @throws {Error} 當未偵測到可用 CLIProxyAPI,或 API 呼叫失敗時。
|
||||
*/
|
||||
export async function chat(systemPrompt, userContent) {
|
||||
const cfg = getLLMConfig();
|
||||
const { provider, command, model } = cfg;
|
||||
if (!provider || !command) throw new Error('未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode');
|
||||
const { provider, baseURL, model } = cfg;
|
||||
if (!provider || !baseURL || !model) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API 與 MODEL');
|
||||
|
||||
line(`[LLM] provider=${provider} command=${command} model=${model}`);
|
||||
line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model}`);
|
||||
|
||||
try {
|
||||
const content = await runAssistantCLI(cfg, buildPrompt(systemPrompt, userContent));
|
||||
recordUsage(null);
|
||||
return content;
|
||||
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 = summarizeCliError(e);
|
||||
line(`[LLM] ${provider} CLI 呼叫失敗: ${message}`);
|
||||
const message = summarizeApiError(e);
|
||||
line(`[LLM] ${provider} API 呼叫失敗: ${message}`);
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 對 AI 助理 CLI 送出對話並將回應解析為 JSON 物件/陣列。
|
||||
* 對 CLIProxyAPI 送出對話並將回應解析為 JSON 物件/陣列。
|
||||
*
|
||||
* 先取得文字回應,經 {@link extractJSONText} 抽出 JSON 片段後解析。
|
||||
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
|
||||
|
||||
Reference in New Issue
Block a user