Merge pull request 'refactor: switch ai-code-review to CLIProxyAPI' (#1) from chore/cliproxyapi-proxy into develop
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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 顯示名稱。
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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('抽不到錯誤行時退取尾段(不取開頭)', () => {
|
||||
|
||||
@@ -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
@@ -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 }; },
|
||||
|
||||
@@ -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 無法取得' };
|
||||
|
||||
Reference in New Issue
Block a user