Merge pull request 'refactor: switch ai-code-review to CLIProxyAPI' (#1) from chore/cliproxyapi-proxy into develop
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Skipped
CI / 3. RESULT (pull_request) Skipped

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-08-07 03:46:09 +00:00
11 changed files with 341 additions and 543 deletions
-14
View File
@@ -72,26 +72,12 @@ jobs:
VERSION: ${{ needs.build.outputs.version }}
# test job 的步驟。
steps:
# 安裝或設定 LLM CLI。
- name: Setup LLM CLI
# 使用對應的 setup action。
uses: https://gitea.jsc.idv.tw/actions/setup-${{ vars.ACTION_SETUP_LLM_CLI }}
# 傳入設定。
with:
# LLM CLI 的 OAuth 憑證。
oauth: ${{ secrets.LLM_OAUTH }}
# 執行 AI Code Review action。
- name: Run AI Code Review
# step id,方便追蹤。
id: ai-code-review
# 使用本 repo 發佈的 action。
uses: https://gitea.jsc.idv.tw/actions/ai-code-review@v${{ env.VERSION }}
# action 參數。
with:
# 存取 Gitea API 的 token。
token: ${{ secrets.TOKEN }}
# 指定 LLM 模型名稱。
model: ${{ vars.LLM_NAME }}
# 第三個 job:輸出最終版本資訊。
result:
# job 顯示名稱。
+12 -28
View File
@@ -6,7 +6,7 @@
| 專案名稱 | 專案描述 |
| --- | --- |
| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/) | 此專案提供 Gitea 工作流程中的 AI 程式碼審查、findings / exclusions 管理、LLM CLI 橋接與 git / Gitea 前置驗證工具。 |
| [AI Code Review](https://gitea.jsc.idv.tw/actions/ai-code-review/src/branch/develop/) | 此專案提供 Gitea 工作流程中的 AI 程式碼審查、findings / exclusions 管理、CLIProxyAPI 橋接與 git / Gitea 前置驗證工具。 |
| 專案名稱 | 參考專案列表 |
| --- | --- |
@@ -31,8 +31,7 @@
| [postNewNonCriticalComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L288) | [發布新問題中的非嚴重 comment。](#postnewnoncriticalcomment) |
| [postNewCriticalComments](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/comments.js#L304) | [發布新嚴重問題的 comment。](#postnewcriticalcomments) |
| [getInsecureHttpsAgent](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/config.js#L60) | [取得一個關閉 TLS 憑證驗證的 HTTPS Agent 單例,供內部服務連線使用。](#getinsecurehttpsagent) |
| [getLLMCLICommands](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/config.js#L100) | [取得目前支援的 LLM CLI 指令名稱清單。](#getllmclicommands) |
| [getLLMConfig](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/config.js#L130) | [依環境變數與 CLI 可用性解析目前可用的 LLM 提供者設定。](#getllmconfig) |
| [getLLMConfig](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/config.js#L130) | [依環境變數解析目前可用的 CLIProxyAPI 設定。](#getllmconfig) |
| [analyzeWithRole](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L14) | [用指定角色分析 diff 並產生 findings。](#analyzewithrole) |
| [normalizeText](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L120) | [將文字正規化成比對用形式。](#normalizetext) |
| [loadOldFindings](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/findings.js#L302) | [讀取舊 findings 並標記為舊問題。](#loadoldfindings) |
@@ -72,7 +71,7 @@
| [ensureJSONArrayFileExists](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/json.js#L134) | [確保指定路徑存在一個 JSON 檔案,不存在時建立空陣列檔。](#ensurejsonarrayfileexists) |
| [mapWithConcurrency](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L25) | [以可控制併發數的方式並行處理陣列項目。](#mapwithconcurrency) |
| [extractMeaningfulError](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L96) | [從 CLI 原始輸出中擷取最有用的錯誤訊息。](#extractmeaningfulerror) |
| [chat](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L190) | [呼叫可用的 AI 助理 CLI,並回傳文字回應。](#chat) |
| [chat](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L190) | [呼叫 CLIProxyAPI,並回傳文字回應。](#chat) |
| [chatJSON](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L218) | [呼叫 AI 助理並把回應解析成 JSON。](#chatjson) |
| [extractBalancedJSON](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L255) | [從指定索引開始擷取完整平衡的 JSON 片段。](#extractbalancedjson) |
| [extractJSONText](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/llm.js#L298) | [從雜訊文字中抽出最可能的 JSON 內容。](#extractjsontext) |
@@ -89,8 +88,8 @@
| [checkRequiredEnv](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L60) | [檢查前置驗證所需的必要環境值是否齊全。](#checkrequiredenv) |
| [verifyGiteaToken](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L77) | [驗證 Gitea token 是否可讀取指定 repository。](#verifygiteatoken) |
| [verifyCommentToken](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L94) | [驗證 comment token 是否可用;未提供時回傳 skipped。](#verifycommenttoken) |
| [fetchCodexModels](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L118) | [讀取本機 codex 認證檔並取得目前可用的模型 slug 清單。](#fetchcodexmodels) |
| [verifyLLM](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L171) | [驗證目前環境是否有可用的 LLM CLI 與對應模型設定。](#verifyllm) |
| [fetchLLMModels](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L118) | [呼叫 CLIProxyAPI 的模型清單端點並取得目前可用的模型 id 清單。](#fetchllmmodels) |
| [verifyLLM](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L171) | [驗證目前環境是否有可用的 CLIProxyAPI 設定與對應模型。](#verifyllm) |
| [runPreflight](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/preflight.js#L203) | [執行所有前置驗證流程,任一失敗即回傳 false。](#runpreflight) |
| [parseBotReviewComment](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L51) | [解析 bot 產生的 review comment,還原成 finding 欄位物件。](#parsebotreviewcomment) |
| [groupConversations](https://gitea.jsc.idv.tw/actions/ai-code-review/blob/develop/src/resolve.js#L73) | [依檔案路徑與行號把 review comments 收斂成對話群組。](#groupconversations) |
@@ -247,23 +246,9 @@ const result = getInsecureHttpsAgent();
預期結果:回傳對應資料、設定、字串或布林值。
### <a id="getllmclicommands"></a>getLLMCLICommands
取得目前支援的 LLM CLI 指令名稱清單。
檔案位置:`src/config.js` 第 100 行。
```js
const result = getLLMCLICommands();
```
使用情境:通常在本地資料處理或同步查詢時呼叫。
預期結果:回傳對應資料、設定、字串或布林值。
### <a id="getllmconfig"></a>getLLMConfig
依環境變數與 CLI 可用性解析目前可用的 LLM 提供者設定。
依環境變數解析目前可用的 CLIProxyAPI 設定。
檔案位置:`src/config.js` 第 130 行。
@@ -823,7 +808,7 @@ const result = extractMeaningfulError(raw, limit);
### <a id="chat"></a>chat
呼叫可用的 AI 助理 CLI,並回傳文字回應。
呼叫 CLIProxyAPI,並回傳文字回應。
檔案位置:`src/llm.js` 第 190 行。
@@ -1059,14 +1044,14 @@ const result = await verifyCommentToken(token);
預期結果:回傳驗證成功/失敗的結構化結果。
### <a id="fetchcodexmodels"></a>fetchCodexModels
### <a id="fetchllmmodels"></a>fetchLLMModels
讀取本機 codex 認證檔並取得目前可用的模型 slug 清單。
呼叫 CLIProxyAPI 的模型清單端點並取得目前可用的模型 id 清單。
檔案位置:`src/preflight.js` 第 118 行。
```js
const result = await fetchCodexModels();
const result = await fetchLLMModels();
```
使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。
@@ -1075,12 +1060,12 @@ const result = await fetchCodexModels();
### <a id="verifyllm"></a>verifyLLM
驗證目前環境是否有可用的 LLM CLI 與對應模型設定。
驗證目前環境是否有可用的 CLIProxyAPI 設定與對應模型。
檔案位置:`src/preflight.js` 第 171 行。
```js
const result = await verifyLLM(fetchCodexModelsFn);
const result = await verifyLLM(fetchLLMModelsFn);
```
使用情境:通常在需要等待外部 I/O 或其他非同步回應時呼叫。
@@ -1464,4 +1449,3 @@ const result = formatUsageStatsLine(provider, model, usage, quota, rate);
使用情境:通常在本地資料處理或同步查詢時呼叫。
預期結果:回傳對應資料、設定、字串或布林值。
+19 -73
View File
@@ -1,6 +1,5 @@
import https from 'https';
import fs from 'fs';
import { execFileSync } from 'child_process';
// 本 action 會連接自架 Gitea / OpenCode,部署環境可能使用內部 CA 或自簽憑證。
// 對外部服務請優先使用預設 TLS 驗證;需要內部服務相容時才使用 getInsecureHttpsAgent()。
@@ -37,6 +36,9 @@ export const PR_NUMBER = process.env.PR_NUMBER || (PR.number != null ? String(PR
export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || PR.head?.sha || process.env.GITHUB_SHA || '';
export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || PR.head?.ref || process.env.GITHUB_HEAD_REF || '';
export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || PR.base?.ref || process.env.GITHUB_BASE_REF || '';
export const CLI_PROXY_API = process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '';
export const CLI_PROXY_API_KEY = process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '';
export const LLM_PROVIDER = 'cliproxyapi';
export const FINDINGS_PATH = '.gitea/ai-review/findings.json';
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json';
@@ -64,82 +66,26 @@ export function getInsecureHttpsAgent() {
// 過渡別名:既有呼叫端仍可用 OpenCode 語意名稱;新程式碼請直接使用 getInsecureHttpsAgent。
export const getOpenCodeHttpsAgent = getInsecureHttpsAgent;
const CLI_CANDIDATES = [
{
provider: 'codex',
command: 'codex',
defaultModel: 'gpt-5.4-mini',
},
{
provider: 'claude',
command: 'claude',
defaultModel: 'sonnet',
},
{
provider: 'antigravity',
command: 'agy',
defaultModel: 'gemini-2.5-flash',
},
{
provider: 'antigravity',
command: 'antigravity',
defaultModel: 'gemini-2.5-flash',
},
{
provider: 'opencode',
command: 'opencode',
defaultModel: 'google/gemini-2.5-flash',
},
];
/**
* 取得目前支援的 LLM CLI 指令名稱清單。
* 依環境變數解析並回傳 CLIProxyAPI 設定。
*
* @remarks 內容直接取自 `CLI_CANDIDATES`,若日後候選清單增減,輸出會同步變動。
* 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL,`INPUT_MODEL` / `MODEL`
* 作為模型名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰。
*
* @returns {{ provider: ('cliproxyapi'|null), apiKeys: string[], baseURL: (string|null), model: (string|null), command: null }}
* 設定物件;`provider` 為 `null` 表示沒有可用的 proxy 設定。
*/
export function getLLMCLICommands() {
return CLI_CANDIDATES.map(c => c.command);
}
/**
* 檢查指定 CLI 指令是否可在目前環境中執行。
*
* @param {string} command - 要檢查的指令名稱。
* @returns {boolean} 找得到指令時回傳 `true`,否則回傳 `false`。
* @remarks 透過 `/bin/sh -lc "command -v <command>"` 檢查,屬於同步存在性檢查。
*/
function commandExists(command) {
try {
execFileSync('/bin/sh', ['-lc', `command -v ${command}`], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/**
* 依環境變數解析並回傳 LLM 提供者設定。
*
* 優先使用 `AI_ASSISTANT_CLI` 指定的 CLI;未指定時依序偵測 codex、claude、antigravity、opencode。
* model 依序取 `with: model`(`INPUT_MODEL`)、`MODEL`、相容舊的 `OPENCODE_MODEL`,最後用各 CLI 預設值。
*
* @param {{ commandExistsFn?: (command: string) => boolean }} [deps] - 可注入的 CLI 偵測函式,供測試使用。
* @returns {{ provider: ('codex'|'claude'|'antigravity'|'opencode'|null), apiKeys: string[], baseURL: null, model: (string|null), command: (string|null) }}
* LLM 設定物件;`provider` 為 `null` 表示沒有可用的提供者。
*/
export function getLLMConfig({ commandExistsFn = commandExists } = {}) {
const requested = process.env.AI_ASSISTANT_CLI;
const candidates = requested
? CLI_CANDIDATES.filter(c => c.provider === requested || c.command === requested)
: CLI_CANDIDATES;
const cli = candidates.find(c => commandExistsFn(c.command));
if (!cli) return { provider: null, apiKeys: [], baseURL: null, model: null, command: null };
export function getLLMConfig() {
const baseURL = String(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API || '').trim().replace(/\/$/, '');
const model = process.env.INPUT_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || '';
const apiKey = String(process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY || '').trim();
if (!baseURL) return { provider: null, apiKeys: [], baseURL: null, model: model || null, command: null };
return {
provider: cli.provider,
apiKeys: [cli.provider],
baseURL: null,
model: process.env.INPUT_MODEL || process.env.MODEL || process.env.OPENCODE_MODEL || cli.defaultModel,
command: cli.command,
provider: LLM_PROVIDER,
apiKeys: apiKey ? [apiKey] : [],
baseURL,
model: model || null,
command: null,
};
}
+75 -109
View File
@@ -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 片段後解析。
* 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。
+1 -1
View File
@@ -98,7 +98,7 @@ export async function main() {
step('Step5', '角色分析產生 findings');
const { provider, apiKeys, baseURL, model } = getLLMConfig();
if (!provider) {
result(false, '未設定任何 LLM API Key,請檢查 action inputs');
result(false, '未設定 CLIProxyAPI,請檢查 action env');
process.exit(1);
}
const roles = loadRoles();
+51 -56
View File
@@ -1,7 +1,4 @@
import axios from 'axios';
import fs from 'fs';
import os from 'os';
import { join } from 'path';
import {
GITEA_TOKEN,
GITEA_COMMENT_TOKEN,
@@ -14,9 +11,6 @@ import {
import { verifyRemoteAccess } from './git.js';
import { step, line, ok, error, result } from './log.js';
// codex 內部用來取得帳號可用模型清單的端點;auth 失效時會回 HTTP 401。
const CODEX_MODELS_ENDPOINT = 'https://chatgpt.com/backend-api/codex/models';
const httpsAgent = getInsecureHttpsAgent();
/**
* 組出 Gitea REST API v1 的完整網址。
@@ -62,6 +56,7 @@ export function checkRequiredEnv({ token = GITEA_TOKEN, repo = GITEA_REPOSITORY,
if (!token) missing.push('GITEA_TOKEN');
if (!repo) missing.push('GITEA_REPOSITORY');
if (!pr) missing.push('PR_NUMBER');
if (!(process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API)) missing.push('CLI_PROXY_API');
return { ok: missing.length === 0, missing };
}
@@ -101,83 +96,83 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) {
}
}
/**
* 讀取本機 codex 認證檔,向模型清單端點確認帳號目前可用的模型 slug。
*
* 用途:preflight 期即時分辨「auth 失效(HTTP 401)」與「模型無權限(不在清單)」,
* 不必等到 Step5 每個角色送 prompt 才神秘失敗。只讀清單、不送 prompt,不消耗生成額度。
* 所有錯誤都被攔截並轉為回傳值,不會 throw。
*
* @param {object} [deps] - 可注入相依,供測試避免真的讀檔/打網路。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
* @param {string} [deps.authPath=~/.codex/auth.json] - codex 認證檔路徑。
* @param {string} [deps.clientVersion] - 帶給端點的 client_version 查詢參數。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
* 成功回傳可用模型 slug 陣列;失敗回傳格式化錯誤訊息。
*/
export async function fetchCodexModels({
fetchImpl = fetch,
authPath = join(os.homedir(), '.codex', 'auth.json'),
clientVersion = '0.142.5',
} = {}) {
let auth;
try {
auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
} catch (e) {
return { ok: false, error: `無法讀取 codex 認證檔(${authPath}): ${e.message}` };
function extractModelIds(data) {
if (!data || typeof data !== 'object') return [];
const source = Array.isArray(data.data) ? data.data : (Array.isArray(data.models) ? data.models : []);
return source
.map(model => {
if (typeof model === 'string') return model;
if (model && typeof model === 'object') return model.id || model.slug || model.name || '';
return '';
})
.filter(Boolean);
}
const tokens = auth.tokens || {};
if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' };
const headers = { Authorization: `Bearer ${tokens.access_token}` };
if (tokens.account_id) headers['chatgpt-account-id'] = tokens.account_id;
/**
* 呼叫 CLIProxyAPI 的 /v1/models,確認 proxy 可用與模型清單可讀。
*
* @param {object} [deps] - 可注入相依,供測試避免真的打網路。
* @param {typeof fetch} [deps.fetchImpl=fetch] - HTTP 取得函式。
* @param {string} [deps.baseURL=CLI_PROXY_API] - CLIProxyAPI 基底網址。
* @param {string} [deps.apiKey=CLI_PROXY_API_KEY] - CLIProxyAPI API key(可空白)。
* @returns {Promise<{ok: true, slugs: string[]}|{ok: false, error: string}>}
*/
export async function fetchLLMModels({
fetchImpl = fetch,
baseURL = process.env.INPUT_CLI_PROXY_API || process.env.CLI_PROXY_API,
apiKey = process.env.INPUT_CLI_PROXY_API_KEY || process.env.CLI_PROXY_API_KEY,
} = {}) {
const root = String(baseURL || '').trim().replace(/\/$/, '');
if (!root) return { ok: false, error: '未設定 CLI_PROXY_API' };
const headers = { 'Content-Type': 'application/json' };
if (String(apiKey || '').trim()) headers.Authorization = `Bearer ${apiKey.trim()}`;
let resp;
try {
resp = await fetchImpl(`${CODEX_MODELS_ENDPOINT}?client_version=${clientVersion}`, { headers });
resp = await fetchImpl(`${root}/v1/models`, { headers });
} catch (e) {
return { ok: false, error: `codex 模型清單查詢連線錯誤: ${e.message}` };
return { ok: false, error: `CLIProxyAPI 模型清單查詢連線錯誤: ${e.message}` };
}
if (resp.status === 401) {
return { ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' };
return { ok: false, error: 'CLIProxyAPI 認證失效(HTTP 401)——API key 已被撤銷或過期,請更新 CLI_PROXY_API_KEY secret' };
}
if (!resp.ok) {
return { ok: false, error: `codex 模型清單查詢失敗(HTTP ${resp.status})` };
return { ok: false, error: `CLIProxyAPI 模型清單查詢失敗(HTTP ${resp.status})` };
}
let data;
try {
data = await resp.json();
} catch (e) {
return { ok: false, error: `codex 模型清單回應解析失敗: ${e.message}` };
return { ok: false, error: `CLIProxyAPI 模型清單回應解析失敗: ${e.message}` };
}
const slugs = Array.isArray(data.models) ? data.models.map(m => m.slug).filter(Boolean) : [];
return { ok: true, slugs };
return { ok: true, slugs: extractModelIds(data) };
}
/**
* 驗證 LLM(AI 助理 CLI)設定可用。
* 驗證 LLM proxy 設定可用。
*
* 確認目前環境可偵測到支援的 CLI 且已解析出 model;provider 為 codex 時,
* 額外向模型清單端點確認 auth 有效且設定的 model 在可用清單內(不送 prompt)。
* 確認目前環境可偵測到 CLIProxyAPI 且已解析出 model;額外向模型清單端點確認
* proxy 可連線且設定的 model 在可用清單內(不送 prompt)。
* @param {object} [deps] - 可注入相依,供測試。
* @param {Function} [deps.fetchCodexModelsFn=fetchCodexModels] - codex 模型清單取得函式。
* @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。
* @returns {Promise<
* {ok: true, provider: string, command: string, model: string, models?: string[]} |
* {ok: false, provider?: string, command?: string, model?: string, error: string}
* {ok: true, provider: string, command: null, model: string, models?: string[]} |
* {ok: false, provider?: string, command?: null, model?: string, error: string}
* >}
* 通過時含 provider、command、model(codex 另含 models 清單);未設定 provider 的失敗分支不含 provider。
* 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。
* @remarks 設定來源為 config.js 的 getLLMConfig()。
*/
export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {}) {
export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) {
const { provider, command, model } = getLLMConfig();
if (!provider || !command) return { ok: false, error: '未偵測到可用 AI 助理 CLI,請安裝 codex、claude、antigravity 或 opencode' };
if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' };
if (!model) return { ok: false, provider, error: '未設定 MODEL' };
if (provider === 'codex') {
const models = await fetchCodexModelsFn();
if (provider === 'cliproxyapi') {
const models = await fetchLLMModelsFn();
if (!models.ok) return { ok: false, provider, command, model, error: models.error };
if (!models.slugs.includes(model)) {
return { ok: false, provider, command, model, error: `模型 ${model} 不在 codex 可用清單: [${models.slugs.join(', ')}]` };
return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` };
}
return { ok: true, provider, command, model, models: models.slugs };
}
@@ -186,7 +181,7 @@ export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {})
}
/**
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM CLI。
* 執行所有前置驗證(Step2):環境變數、Gitea token、comment token、git 遠端、LLM proxy。
*
* 全程唯讀,不發布任何 comment;任一檢查失敗即記錄錯誤並回傳 false。
* 各檢查可經 deps 注入覆寫,方便單元測試。
@@ -196,7 +191,7 @@ export async function verifyLLM({ fetchCodexModelsFn = fetchCodexModels } = {})
* @param {Function} [deps.verifyToken=verifyGiteaToken] - Gitea token / repo 讀取驗證。
* @param {Function} [deps.verifyComment=verifyCommentToken] - comment token 驗證。
* @param {Function} [deps.verifyRemote=verifyRemoteAccess] - git 遠端(ls-remote)認證驗證。
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM(AI 助理 CLI)驗證。
* @param {Function} [deps.verifyLLMFn=verifyLLM] - LLM proxy 驗證。
* @returns {Promise<boolean>} 全部通過為 true,任一失敗為 false。
* @remarks 透過 log.js 輸出 step/ok/line/error/result 記錄;不會 throw(前提是注入的檢查函式皆自行攔截錯誤)。
*/
@@ -244,7 +239,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || '
error(`LLM 驗證失敗: ${llm.error}`);
return false;
}
ok(`LLM CLI 可用(command=${llm.command}, provider=${llm.provider}, model=${llm.model})`);
ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model})`);
if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`);
result(true, '前置驗證通過');
+30 -34
View File
@@ -1,9 +1,10 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
const ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL',
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
];
let saved = {};
@@ -19,48 +20,43 @@ afterEach(() => {
});
describe('getLLMConfig', () => {
it('exports the supported assistant CLI commands', () => {
assert.deepEqual(getLLMCLICommands(), ['codex', 'claude', 'agy', 'antigravity', 'opencode']);
});
it('returns null provider when no env vars set', () => {
const cfg = getLLMConfig({ commandExistsFn: () => false });
const cfg = getLLMConfig();
assert.equal(cfg.provider, null);
assert.deepEqual(cfg.apiKeys, []);
assert.equal(cfg.baseURL, null);
assert.equal(cfg.model, null);
});
it('reads CLIProxyAPI settings from env', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig();
assert.equal(cfg.provider, 'cliproxyapi');
assert.deepEqual(cfg.apiKeys, ['secret']);
assert.equal(cfg.baseURL, 'https://proxy.example');
assert.equal(cfg.model, 'gpt-5.5');
assert.equal(cfg.command, null);
});
it('detects the first installed assistant CLI with defaults', () => {
const cfg = getLLMConfig({ commandExistsFn: command => command === 'claude' });
assert.equal(cfg.provider, 'claude');
assert.deepEqual(cfg.apiKeys, ['claude']);
assert.equal(cfg.baseURL, null);
assert.equal(cfg.command, 'claude');
assert.equal(cfg.model, 'sonnet');
});
it('uses INPUT_MODEL over MODEL when both exist', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.INPUT_MODEL = 'gpt-5-mini';
process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig();
it('uses MODEL for the selected assistant CLI', () => {
process.env.MODEL = 'gpt-5-mini';
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' });
assert.equal(cfg.provider, 'codex');
assert.equal(cfg.command, 'codex');
assert.equal(cfg.model, 'gpt-5-mini');
});
it('detects Antigravity through the agy command', () => {
const cfg = getLLMConfig({ commandExistsFn: command => command === 'agy' });
assert.equal(cfg.provider, 'antigravity');
assert.equal(cfg.command, 'agy');
assert.equal(cfg.model, 'gemini-2.5-flash');
});
it('can force a CLI with AI_ASSISTANT_CLI', () => {
process.env.AI_ASSISTANT_CLI = 'opencode';
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === 'opencode' });
assert.equal(cfg.provider, 'opencode');
assert.equal(cfg.command, 'opencode');
assert.equal(cfg.model, 'google/gemini-2.5-pro');
it('returns null provider when CLI_PROXY_API is missing', () => {
process.env.MODEL = 'gpt-5.5';
const cfg = getLLMConfig();
assert.equal(cfg.provider, null);
assert.equal(cfg.baseURL, null);
});
it('uses an insecure HTTPS agent for OpenCode', () => {
+62 -95
View File
@@ -1,104 +1,73 @@
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import axios from 'axios';
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
const ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
'FAKE_AI_STDOUT', 'FAKE_AI_STDERR', 'FAKE_AI_EXIT', 'FAKE_AI_STDIN_PATH', 'FAKE_AI_ARGS_PATH',
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
];
let saved = {};
let tempDir;
beforeEach(() => {
saved = {};
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
tempDir = null;
});
afterEach(async () => {
afterEach(() => {
for (const k of ENV_KEYS) {
if (saved[k] === undefined) delete process.env[k];
else process.env[k] = saved[k];
}
if (tempDir) await rm(tempDir, { recursive: true, force: true });
mock.restoreAll();
});
async function installFakeCLI(command = 'codex') {
tempDir = await mkdtemp(join(tmpdir(), 'ai-cli-test-'));
const script = join(tempDir, command);
await writeFile(script, `#!/bin/sh
if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi
if [ -n "$FAKE_AI_STDIN_PATH" ]; then /bin/cat > "$FAKE_AI_STDIN_PATH"; else /bin/cat >/dev/null; fi
if [ -n "$FAKE_AI_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi
if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi
exit "\${FAKE_AI_EXIT:-0}"
`);
await chmod(script, 0o755);
process.env.PATH = tempDir;
return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') };
}
describe('chat - assistant CLI', async () => {
describe('chat - CLIProxyAPI', async () => {
const { chat } = await import('../llm.js');
it('runs the detected CLI with MODEL and sends the prompts through stdin', async () => {
const { stdinPath, argsPath } = await installFakeCLI('codex');
it('posts the prompts to /v1/chat/completions and returns the response text', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'gpt-5-mini';
process.env.FAKE_AI_STDOUT = 'cli response';
process.env.FAKE_AI_STDIN_PATH = stdinPath;
process.env.FAKE_AI_ARGS_PATH = argsPath;
let capturedUrl, capturedBody, capturedOpts;
mock.method(axios, 'post', async (url, body, opts) => {
capturedUrl = url;
capturedBody = body;
capturedOpts = opts;
return {
data: {
choices: [{ message: { content: 'cli response' } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
},
headers: { 'x-ratelimit-remaining-tokens': '80', 'x-ratelimit-limit-tokens': '100' },
};
});
const result = await chat('sys', 'user');
assert.equal(result, 'cli response');
assert.match(await readFile(argsPath, 'utf8'), /exec --model gpt-5-mini/);
const prompt = await readFile(stdinPath, 'utf8');
assert.match(prompt, /<system>\nsys\n<\/system>/);
assert.match(prompt, /<user>\nuser\n<\/user>/);
assert.equal(capturedUrl, 'https://proxy.example/v1/chat/completions');
assert.equal(capturedBody.model, 'gpt-5-mini');
assert.deepEqual(capturedBody.messages, [
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
{ role: 'user', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。\n\n<system>\nsys\n</system>\n\n<user>\nuser\n</user>' },
]);
assert.equal(capturedBody.temperature, 0);
assert.equal(capturedBody.stream, false);
assert.equal(capturedOpts.headers.Authorization, 'Bearer secret');
});
it('can force opencode with AI_ASSISTANT_CLI', async () => {
const { argsPath } = await installFakeCLI('opencode');
process.env.AI_ASSISTANT_CLI = 'opencode';
process.env.MODEL = 'google/gemini-2.5-pro';
process.env.FAKE_AI_STDOUT = 'ok';
process.env.FAKE_AI_ARGS_PATH = argsPath;
const result = await chat('sys', 'user');
assert.equal(result, 'ok');
assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/);
it('throws an error when the API fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => {
const e = new Error('Request failed');
e.response = { status: 401, data: { error: { message: 'access token revoked' } } };
throw e;
});
it('runs Antigravity through agy with MODEL and prompt argument', async () => {
const { stdinPath, argsPath } = await installFakeCLI('agy');
process.env.AI_ASSISTANT_CLI = 'agy';
process.env.MODEL = 'gemini-2.5-pro';
process.env.FAKE_AI_STDOUT = 'antigravity response';
process.env.FAKE_AI_STDIN_PATH = stdinPath;
process.env.FAKE_AI_ARGS_PATH = argsPath;
const result = await chat('sys', 'user');
assert.equal(result, 'antigravity response');
const args = await readFile(argsPath, 'utf8');
assert.match(args, /-p .*--model gemini-2.5-pro/s);
assert.match(args, /<system>\nsys\n<\/system>/);
assert.equal(await readFile(stdinPath, 'utf8'), '');
});
it('throws an error when the CLI fails instead of exiting the process', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_EXIT = '2';
process.env.FAKE_AI_STDERR = 'provider overloaded';
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); });
await assert.rejects(() => chat('sys', 'user'), /provider overloaded/);
assert.equal(exitMock.mock.calls.length, 0);
await assert.rejects(() => chat('sys', 'user'), /401/);
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
});
});
@@ -106,8 +75,9 @@ describe('chatJSON', async () => {
const { chatJSON } = await import('../llm.js');
it('parses plain JSON response', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]';
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '[{"level":"critical"}]' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user');
@@ -115,8 +85,9 @@ describe('chatJSON', async () => {
});
it('strips markdown code block before parsing', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```';
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '```json\n[{"level":"info"}]\n```' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user');
@@ -124,8 +95,9 @@ describe('chatJSON', async () => {
});
it('extracts JSON array from surrounding prose', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.';
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user');
@@ -133,8 +105,9 @@ describe('chatJSON', async () => {
});
it('extracts JSON object from surrounding prose', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}';
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user');
@@ -142,8 +115,9 @@ describe('chatJSON', async () => {
});
it('returns [] when JSON is invalid', async () => {
await installFakeCLI('codex');
process.env.FAKE_AI_STDOUT = 'not json';
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5-mini';
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: 'not json' } }] }, headers: {} }));
const result = await chatJSON('sys', 'user');
@@ -239,26 +213,19 @@ describe('extractJSONText', () => {
});
describe('extractMeaningfulError', () => {
it('抽出尾端真正的錯誤,而非開頭的 codex banner/回顯 prompt', () => {
it('抽出尾端真正的錯誤,而非前段雜訊', () => {
const raw = [
'OpenAI Codex v0.142.5',
'--------',
'workdir: /workspace/actions/ai-code-review',
'model: gpt-5.4-mini',
'reasoning effort: none',
'--------',
'user',
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
'ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 401 Unauthorized',
'ERROR: Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
'HTTP/1.1 401 Unauthorized',
'{"error":{"message":"access token revoked"}}',
'trace: proxy request failed',
'ERROR: access token revoked',
].join('\n');
const result = extractMeaningfulError(raw);
assert.match(result, /401 Unauthorized/);
assert.match(result, /refresh token was revoked/);
assert.doesNotMatch(result, /workdir:/);
assert.doesNotMatch(result, /請依照以下系統指示/);
assert.match(result, /access token revoked/);
assert.doesNotMatch(result, /trace:/);
});
it('抽不到錯誤行時退取尾段(不取開頭)', () => {
+1 -1
View File
@@ -17,7 +17,7 @@ function baseStubs() {
config: {
GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop',
FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json',
getLLMConfig: () => ({ provider: 'codex', apiKeys: ['codex'], baseURL: null, model: 'gpt-5.5', command: 'codex' }),
getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: 'gpt-5.5', command: null }),
},
roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' },
gitea: {
+87 -130
View File
@@ -1,52 +1,38 @@
import { describe, it, afterEach, mock } from 'node:test';
import assert from 'node:assert/strict';
import axios from 'axios';
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js';
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js';
const LLM_ENV_KEYS = [
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
];
const ORIGINAL_PATH = process.env.PATH;
function clearLLMEnv() {
for (const k of LLM_ENV_KEYS) delete process.env[k];
}
let tempDir;
afterEach(async () => {
afterEach(() => {
mock.restoreAll();
clearLLMEnv();
process.env.PATH = ORIGINAL_PATH;
if (tempDir) await rm(tempDir, { recursive: true, force: true });
tempDir = null;
});
async function installFakeCLI(command = 'codex') {
tempDir = await mkdtemp(join(tmpdir(), 'preflight-cli-test-'));
const script = join(tempDir, command);
await writeFile(script, '#!/bin/sh\nexit 0\n');
await chmod(script, 0o755);
process.env.PATH = tempDir;
}
describe('checkRequiredEnv', () => {
it('reports all three missing when nothing provided', () => {
it('reports all missing values when nothing is provided', () => {
const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
assert.equal(result.ok, false);
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER']);
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER', 'CLI_PROXY_API']);
});
it('reports only the missing ones', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = checkRequiredEnv({ token: 't', repo: '', pr: '5' });
assert.equal(result.ok, false);
assert.deepEqual(result.missing, ['GITEA_REPOSITORY']);
});
it('ok when all provided', () => {
it('ok when all required values are provided', () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = checkRequiredEnv({ token: 't', repo: 'owner/repo', pr: '5' });
assert.equal(result.ok, true);
assert.deepEqual(result.missing, []);
@@ -84,7 +70,7 @@ describe('verifyGiteaToken', () => {
});
describe('verifyCommentToken', () => {
it('skips when no comment token provided', async () => {
it('skips when no comment token is provided', async () => {
const result = await verifyCommentToken('');
assert.deepEqual(result, { ok: true, skipped: true });
});
@@ -118,129 +104,93 @@ describe('verifyCommentToken', () => {
});
});
describe('verifyLLM', () => {
it('fails when no supported assistant CLI is detected', async () => {
clearLLMEnv();
process.env.AI_ASSISTANT_CLI = 'no-such-ai-cli';
process.env.PATH = '';
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /AI 助理 CLI/);
});
it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, true);
assert.equal(result.provider, 'codex');
assert.equal(result.command, 'codex');
assert.equal(result.model, 'gpt-5.4-mini');
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('fails when codex auth is invalid (model list check reports 401)', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'codex');
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
});
it('fails when the configured model is not in the codex available list', async () => {
clearLLMEnv();
await installFakeCLI('codex');
process.env.AI_ASSISTANT_CLI = 'codex';
process.env.MODEL = 'gpt-9-imaginary';
const result = await verifyLLM({
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, false);
assert.match(result.error, /不在 codex 可用清單/);
assert.match(result.error, /gpt-5\.4-mini/);
});
it('fails when a requested CLI is not installed', async () => {
clearLLMEnv();
process.env.AI_ASSISTANT_CLI = 'missing-cli';
process.env.PATH = '';
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /AI 助理 CLI/);
});
});
describe('fetchCodexModels', () => {
async function writeAuth(json) {
tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-'));
const authPath = join(tempDir, 'auth.json');
await writeFile(authPath, JSON.stringify(json));
return authPath;
}
it('returns the model slugs on HTTP 200', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } });
describe('fetchLLMModels', () => {
it('returns the model ids on HTTP 200', async () => {
let capturedUrl, capturedHeaders;
const result = await fetchCodexModels({
authPath,
const result = await fetchLLMModels({
baseURL: 'https://proxy.example/',
apiKey: 'tok',
fetchImpl: async (url, opts) => {
capturedUrl = url;
capturedHeaders = opts.headers;
return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) };
return { status: 200, ok: true, json: async () => ({ data: [{ id: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }, 'custom-model'] }) };
},
});
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] });
assert.match(capturedUrl, /client_version=/);
assert.equal(capturedHeaders['Authorization'], 'Bearer tok');
assert.equal(capturedHeaders['chatgpt-account-id'], 'acc');
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini', 'custom-model'] });
assert.equal(capturedUrl, 'https://proxy.example/v1/models');
assert.equal(capturedHeaders.Authorization, 'Bearer tok');
});
it('reports an auth failure on HTTP 401', async () => {
const authPath = await writeAuth({ tokens: { access_token: 'revoked' } });
const result = await fetchCodexModels({
authPath,
const result = await fetchLLMModels({
baseURL: 'https://proxy.example',
apiKey: 'revoked',
fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }),
});
assert.equal(result.ok, false);
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /LLM_OAUTH/);
assert.match(result.error, /CLIProxyAPI/);
});
it('fails when the auth file cannot be read', async () => {
const result = await fetchCodexModels({
authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'),
fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }),
});
it('fails when the base URL is missing', async () => {
const result = await fetchLLMModels({ baseURL: '', apiKey: 'tok', fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
assert.equal(result.ok, false);
assert.match(result.error, /無法讀取 codex 認證檔/);
assert.match(result.error, /CLI_PROXY_API/);
});
});
it('fails when the auth file lacks an access_token', async () => {
const authPath = await writeAuth({ tokens: {} });
const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
describe('verifyLLM', () => {
it('fails when no CLIProxyAPI is configured', async () => {
clearLLMEnv();
const result = await verifyLLM();
assert.equal(result.ok, false);
assert.match(result.error, /缺少 tokens\.access_token/);
assert.match(result.error, /CLIProxyAPI/);
});
it('passes when the configured model is in the proxy model list', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.CLI_PROXY_API_KEY = 'secret';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, true);
assert.equal(result.provider, 'cliproxyapi');
assert.equal(result.command, null);
assert.equal(result.model, 'gpt-5.4-mini');
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
});
it('fails when proxy auth is invalid', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-5.4-mini';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: false, error: 'CLIProxyAPI 認證失效(HTTP 401)——API key 已被撤銷或過期,請更新 CLI_PROXY_API_KEY secret' }),
});
assert.equal(result.ok, false);
assert.equal(result.provider, 'cliproxyapi');
assert.match(result.error, /HTTP 401/);
assert.match(result.error, /CLI_PROXY_API_KEY/);
});
it('fails when the configured model is not in the proxy available list', async () => {
clearLLMEnv();
process.env.CLI_PROXY_API = 'https://proxy.example';
process.env.MODEL = 'gpt-9-imaginary';
const result = await verifyLLM({
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
});
assert.equal(result.ok, false);
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
assert.match(result.error, /gpt-9-imaginary/);
});
});
@@ -251,7 +201,7 @@ describe('runPreflight', () => {
verifyToken: async () => ({ ok: true }),
verifyComment: async () => ({ ok: true }),
verifyRemote: () => ({ ok: true }),
verifyLLMFn: async () => ({ ok: true, provider: 'codex' }),
verifyLLMFn: async () => ({ ok: true, provider: 'cliproxyapi', command: null, model: 'gpt-5.5', models: ['gpt-5.5'] }),
...overrides,
};
}
@@ -262,11 +212,13 @@ describe('runPreflight', () => {
});
it('returns true when every verification step succeeds', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps());
assert.equal(result, true);
});
it('returns true when the comment token check is skipped', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps({
verifyComment: async () => ({ ok: true, skipped: true }),
}));
@@ -274,6 +226,7 @@ describe('runPreflight', () => {
});
it('returns false when the Gitea token check fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
let remoteCalled = false;
const result = await runPreflight('/ws', makeDeps({
verifyToken: async () => ({ ok: false, error: 'HTTP 401' }),
@@ -284,6 +237,7 @@ describe('runPreflight', () => {
});
it('returns false when the comment token check fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps({
verifyComment: async () => ({ ok: false, error: 'HTTP 401' }),
}));
@@ -291,6 +245,7 @@ describe('runPreflight', () => {
});
it('returns false when git remote access fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
let llmCalled = false;
const result = await runPreflight('/ws', makeDeps({
verifyRemote: () => ({ ok: false, error: 'auth failed' }),
@@ -301,13 +256,15 @@ describe('runPreflight', () => {
});
it('returns false when LLM verification fails', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
const result = await runPreflight('/ws', makeDeps({
verifyLLMFn: async () => ({ ok: false, error: 'AI 助理 CLI 驗證失敗' }),
verifyLLMFn: async () => ({ ok: false, error: 'CLIProxyAPI 驗證失敗' }),
}));
assert.equal(result, false);
});
it('passes the workspace through to the remote-access check', async () => {
process.env.CLI_PROXY_API = 'https://proxy.example';
let captured;
await runPreflight('/custom/ws', makeDeps({
verifyRemote: (ws) => { captured = ws; return { ok: true }; },
+1
View File
@@ -190,6 +190,7 @@ async function fetchOpenRouterQuota({ apiKey, baseURL }, get) {
* 本地/自架服務(ollama/opencode)則回報「不適用」。
*/
const QUOTA_STRATEGIES = {
cliproxyapi: async () => ({ available: false, reason: 'CLIProxyAPI 不提供帳號額度資訊' }),
openai: async (cfg, get) => {
if (isOpenRouterBaseURL(cfg.baseURL)) return fetchOpenRouterQuota(cfg, get);
return { available: false, reason: 'OpenAI 帳號額度需 dashboard session 權限,API key 無法取得' };