From 21687fd2bd36cdf33af5fa9c4570c4b773819907 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 6 Aug 2026 17:42:49 +0000 Subject: [PATCH] refactor: switch ai-code-review to CLIProxyAPI --- .gitea/workflows/ci.yaml | 14 --- README.md | 40 ++----- src/config.js | 92 ++++------------ src/llm.js | 184 +++++++++++++------------------ src/main.js | 2 +- src/preflight.js | 109 +++++++++---------- src/test/config.test.js | 64 +++++------ src/test/llm.test.js | 159 +++++++++++---------------- src/test/main.test.js | 2 +- src/test/preflight.test.js | 217 +++++++++++++++---------------------- src/usage.js | 1 + 11 files changed, 341 insertions(+), 543 deletions(-) diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 454ca0b..0125fd1 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 顯示名稱。 diff --git a/README.md b/README.md index a999bd1..7ea73e4 100644 --- a/README.md +++ b/README.md @@ -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(); 預期結果:回傳對應資料、設定、字串或布林值。 -### getLLMCLICommands - -取得目前支援的 LLM CLI 指令名稱清單。 - -檔案位置:`src/config.js` 第 100 行。 - -```js -const result = getLLMCLICommands(); -``` - -使用情境:通常在本地資料處理或同步查詢時呼叫。 - -預期結果:回傳對應資料、設定、字串或布林值。 - ### getLLMConfig -依環境變數與 CLI 可用性解析目前可用的 LLM 提供者設定。 +依環境變數解析目前可用的 CLIProxyAPI 設定。 檔案位置:`src/config.js` 第 130 行。 @@ -823,7 +808,7 @@ const result = extractMeaningfulError(raw, limit); ### chat -呼叫可用的 AI 助理 CLI,並回傳文字回應。 +呼叫 CLIProxyAPI,並回傳文字回應。 檔案位置:`src/llm.js` 第 190 行。 @@ -1059,14 +1044,14 @@ const result = await verifyCommentToken(token); 預期結果:回傳驗證成功/失敗的結構化結果。 -### fetchCodexModels +### 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(); ### 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); 使用情境:通常在本地資料處理或同步查詢時呼叫。 預期結果:回傳對應資料、設定、字串或布林值。 - diff --git a/src/config.js b/src/config.js index 722127d..e308cb7 100644 --- a/src/config.js +++ b/src/config.js @@ -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 "` 檢查,屬於同步存在性檢查。 - */ -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, }; } diff --git a/src/llm.js b/src/llm.js index 1e9e4d9..2e55711 100644 --- a/src/llm.js +++ b/src/llm.js @@ -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} 模型回應的純文字內容。 - * @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 片段後解析。 * 解析失敗時記錄錯誤並回傳空陣列,不向外拋錯(容錯設計)。 diff --git a/src/main.js b/src/main.js index 34fc52b..69216e8 100644 --- a/src/main.js +++ b/src/main.js @@ -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(); diff --git a/src/preflight.js b/src/preflight.js index cbf7a9e..729db59 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -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}` }; - } - const tokens = auth.tokens || {}; - if (!tokens.access_token) return { ok: false, error: 'codex 認證檔缺少 tokens.access_token' }; +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 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} 全部通過為 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, '前置驗證通過'); diff --git a/src/test/config.test.js b/src/test/config.test.js index 986b349..b31f44f 100644 --- a/src/test/config.test.js +++ b/src/test/config.test.js @@ -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', () => { diff --git a/src/test/llm.test.js b/src/test/llm.test.js index cba53ed..8c17a57 100644 --- a/src/test/llm.test.js +++ b/src/test/llm.test.js @@ -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, /\nsys\n<\/system>/); - assert.match(prompt, /\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\nsys\n\n\n\nuser\n' }, + ]); + 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; + 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; + }); - const result = await chat('sys', 'user'); - - assert.equal(result, 'ok'); - assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/); - }); - - 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, /\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('抽不到錯誤行時退取尾段(不取開頭)', () => { diff --git a/src/test/main.test.js b/src/test/main.test.js index fdb818d..71acef1 100644 --- a/src/test/main.test.js +++ b/src/test/main.test.js @@ -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: { diff --git a/src/test/preflight.test.js b/src/test/preflight.test.js index 359f4e7..4e2361c 100644 --- a/src/test/preflight.test.js +++ b/src/test/preflight.test.js @@ -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, /CLI_PROXY_API/); + }); +}); + +describe('verifyLLM', () => { + it('fails when no CLIProxyAPI is configured', async () => { + clearLLMEnv(); + const result = await verifyLLM(); + assert.equal(result.ok, false); + 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, false); - assert.match(result.error, /無法讀取 codex 認證檔/); + + 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 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 () => ({}) }) }); + 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.match(result.error, /缺少 tokens\.access_token/); + 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 }; }, diff --git a/src/usage.js b/src/usage.js index 93c53f7..1f58473 100644 --- a/src/usage.js +++ b/src/usage.js @@ -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 無法取得' };