From 651e221e900abc0505c7a0ce282daf246624d179 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Fri, 7 Aug 2026 06:16:45 +0000 Subject: [PATCH] allow proxy auto model selection --- action.yml | 6 +++++- readme.md | 10 +++++----- src/config.js | 8 +++++--- src/llm.js | 27 ++++++++++++++------------- src/main.js | 11 ++++++----- src/preflight.js | 16 ++++++---------- src/test/config.test.js | 14 ++++++++++++-- src/test/llm.test.js | 21 ++++++++++++++++++++- src/test/main.test.js | 6 ++++++ src/test/preflight.test.js | 18 +++++++++++++++++- 10 files changed, 96 insertions(+), 41 deletions(-) diff --git a/action.yml b/action.yml index 7d8b769..f1badc1 100644 --- a/action.yml +++ b/action.yml @@ -8,6 +8,9 @@ inputs: comment_token: description: '操作 Gitea Commit API 的 Token' required: false + model: + description: '使用的 AI 模型' + required: false runs: using: 'docker' image: 'dockerfile' @@ -15,4 +18,5 @@ runs: GITEA_TOKEN: ${{ inputs.token || secrets.TOKEN || gitea.token }} GITEA_COMMENT_TOKEN: ${{ inputs.comment_token || inputs.token || secrets.TOKEN || gitea.token }} CLI_PROXY_API: ${{ vars.CLI_PROXY_API }} - CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }} \ No newline at end of file + CLI_PROXY_API_KEY: ${{ secrets.CLI_PROXY_API_KEY }} + CLI_PROXY_API_MODEL: ${{ inputs.model || vars.CLI_PROXY_API_MODEL }} \ No newline at end of file diff --git a/readme.md b/readme.md index af47163..42b01e6 100644 --- a/readme.md +++ b/readme.md @@ -293,7 +293,7 @@ await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent }); ### getLLMConfig -依環境變數解析並回傳 CLIProxyAPI 設定:`INPUT_CLI_PROXY_API`/`CLI_PROXY_API` 作 base URL(trim 並去尾斜線),`INPUT_MODEL`/`MODEL`/`OPENCODE_MODEL` 依序 fallback 作模型名稱,`INPUT_CLI_PROXY_API_KEY`/`CLI_PROXY_API_KEY` 作金鑰。base URL 無法解析時 `provider`/`baseURL` 回 `null`、`apiKeys` 回空陣列,但 `model`(若有)仍會回傳。 +依環境變數解析並回傳 CLIProxyAPI 設定:`INPUT_CLI_PROXY_API`/`CLI_PROXY_API` 作 base URL(trim 並去尾斜線),`INPUT_MODEL`/`CLI_PROXY_API_MODEL`/`MODEL`/`OPENCODE_MODEL` 依序 fallback 作可選模型名稱;若未指定模型,會交由 CLIProxyAPI 自動選擇,`INPUT_CLI_PROXY_API_KEY`/`CLI_PROXY_API_KEY` 作金鑰。base URL 無法解析時 `provider`/`baseURL` 回 `null`、`apiKeys` 回空陣列,但 `model`(若有)仍會回傳。 - 參數:無。 - 回傳:`{ provider, apiKeys, baseURL, model, command }`。 @@ -301,9 +301,9 @@ await axios.get('https://internal-gitea.example/api/v1/user', { httpsAgent }); ```javascript import { getLLMConfig } from './src/config.js'; -// 環境變數:CLI_PROXY_API=https://proxy.example, MODEL=gpt-4o, CLI_PROXY_API_KEY=sk-xxx +// 環境變數:CLI_PROXY_API=https://proxy.example, CLI_PROXY_API_KEY=sk-xxx const cfg = getLLMConfig(); -// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: 'gpt-4o', command: null } +// => { provider: 'cliproxyapi', apiKeys: ['sk-xxx'], baseURL: 'https://proxy.example', model: null, command: null } ``` @@ -972,7 +972,7 @@ extractMeaningfulError('some noise\nERROR: rate limit exceeded\nmore noise'); - 例外:設定缺失、API 呼叫失敗或回應無文字內容時拋出。 ```javascript -// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API / MODEL)。 +// 範例為示意,實際呼叫需搭配有效的 CLIProxyAPI 環境變數(CLI_PROXY_API;MODEL 可省略)。 import { chat } from './src/llm.js'; const reply = await chat('你是程式碼審查員', '請審查以下 diff:...'); @@ -1251,7 +1251,7 @@ const result = await fetchLLMModels(); ### verifyLLM -驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI 且已解析出 model,額外向模型清單端點確認 proxy 可連線且設定的 model 在可用清單內(不送 prompt)。 +驗證 LLM proxy 設定可用:確認目前環境可偵測到 CLIProxyAPI;若有指定 model,額外向模型清單端點確認該 model 在可用清單內(不送 prompt)。未指定 model 時,只要求 proxy 與模型清單端點可連線。 - 參數:`deps.fetchLLMModelsFn`(預設 `fetchLLMModels`)。 - 回傳:`Promise<{ok:true, provider, command, model, models?} | {ok:false, provider?, command?, model?, error}>`。 diff --git a/src/config.js b/src/config.js index 11e3677..bcfde41 100644 --- a/src/config.js +++ b/src/config.js @@ -65,8 +65,10 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; * 依環境變數解析並回傳 CLIProxyAPI 設定。 * * 優先讀取 `INPUT_CLI_PROXY_API` / `CLI_PROXY_API` 作為 base URL(會 trim 並移除結尾斜線), - * `INPUT_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback,相容舊 OpenCode 設定)作為模型 - * 名稱,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` 作為存取金鑰(會 trim)。 + * `INPUT_MODEL` / `CLI_PROXY_API_MODEL` / `MODEL` / `OPENCODE_MODEL`(依序 fallback, + * 相容 action input、舊 OpenCode 設定與環境變數)作為可選模型名稱;若未提供, + * 則交由 CLIProxyAPI 自動選擇模型,`INPUT_CLI_PROXY_API_KEY` / `CLI_PROXY_API_KEY` + * 作為存取金鑰(會 trim)。 * * 若 base URL 無法解析出任何值,視為沒有可用的 proxy 設定:`provider`/`baseURL` 回傳 `null`、 * `apiKeys` 回傳空陣列,但 `model`(若有解析到)仍會回傳,不會被清空。 @@ -76,7 +78,7 @@ export const getOpenCodeHttpsAgent = getInsecureHttpsAgent; */ 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 model = process.env.INPUT_MODEL || process.env.CLI_PROXY_API_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 }; diff --git a/src/llm.js b/src/llm.js index adb7d06..677669a 100644 --- a/src/llm.js +++ b/src/llm.js @@ -147,8 +147,8 @@ function summarizeApiError(e) { * 由 axios 直接拋出例外,交由呼叫端(chat())攔截並摘要。僅使用 * `apiKeys` 陣列的第一個元素,不會輪替其他金鑰。 * - * @param {{provider: string, baseURL: string, apiKeys: string[], model: string}} cfg - 連線設定; - * 僅使用 `apiKeys[0]`。 + * @param {{provider: string, baseURL: string, apiKeys: string[], model?: string|null}} cfg - 連線設定; + * 僅使用 `apiKeys[0]`;`model` 可省略,省略時交由 CLIProxyAPI 自動選擇。 * @param {string} prompt - 送給 API 的完整 prompt 內容,會作為 user 訊息內容; * HTTP 層的 system 訊息為固定的通用指示,與 prompt 內可能內嵌的 `` 內容無關。 * @returns {Promise} API 回應的原始資料物件(`resp.data`),並非純文字; @@ -164,17 +164,18 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { 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 body = { + messages: [ + { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, + { role: 'user', content: prompt }, + ], + temperature: 0, + stream: false, + }; + if (model) body.model = model; const resp = await axios.post( `${root}/v1/chat/completions`, - { - model, - messages: [ - { role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' }, - { role: 'user', content: prompt }, - ], - temperature: 0, - stream: false, - }, + body, { timeout, maxBodyLength: maxBuffer, @@ -209,9 +210,9 @@ async function runProxyAPI({ provider, baseURL, apiKeys, model }, prompt) { export async function chat(systemPrompt, userContent) { const cfg = getLLMConfig(); const { provider, baseURL, model } = cfg; - if (!provider || !baseURL || !model) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API 與 MODEL'); + if (!provider || !baseURL) throw new Error('未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API'); - line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model}`); + line(`[LLM] provider=${provider} baseURL=${baseURL} model=${model || 'auto'}`); try { const data = await runProxyAPI(cfg, buildPrompt(systemPrompt, userContent)); diff --git a/src/main.js b/src/main.js index 7676d44..1f3528e 100644 --- a/src/main.js +++ b/src/main.js @@ -37,7 +37,7 @@ const WORKSPACE = process.env.GITHUB_WORKSPACE || '/workspace'; * - Step3 自動提交檢查:偵測上輪 bot `[failure]`(exit 1)或本次為 bot 自動提交(exit 0 跳過)。 * - Step4 PR 對話收斂:關閉未解決 comment 並將 finding 分流為已修復 / 誤報 / 仍成立(失敗則降級繼續)。 * - Step5 角色分析:載入角色、取 PR diff,平行產生 findings 並補齊缺漏行號; - * 未設定 API Key 或取 diff 失敗 exit 1,diff 為空 exit 0。 + * 未設定 CLIProxyAPI 或取 diff 失敗 exit 1,diff 為空 exit 0。 * - Step6 合併去重:舊 findings + 對話收斂結果 + 新 findings → 語意去重並排序。 * - Step7 過濾:套用排除規則 + 防守方 AI 誤報裁決。 * - Step8 發布:寫入 findings、組裝使用量,發布 Gitea Review(失敗則降級繼續)。 @@ -119,9 +119,10 @@ export async function main() { section('Pipeline 結束'); process.exit(0); } - input(`LLM=${provider}/${model};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`); + const modelLabel = model || 'auto'; + input(`LLM=${provider}/${modelLabel};角色=[${roles.map(r => r.name).join(', ')}];diff=${diff.length} 字元`); try { - await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${model}`); + await postComment(getRoleIntro(roles) + `\n\n> 🔍 服務:${provider} 模型:${modelLabel}`); line('角色介紹 comment 已發布'); } catch (e) { warn(`角色介紹 comment 發布失敗(繼續執行): ${e.message}`); @@ -191,9 +192,9 @@ export async function main() { const runUsage = getRunUsage(); const quota = await fetchAccountQuota(provider, { apiKeys, baseURL }); const rate = getRateLimit(); - const usageSection = formatUsageStats(provider, model, runUsage, quota, rate); + const usageSection = formatUsageStats(provider, modelLabel, runUsage, quota, rate); input(`findings ${filtered.length} 筆(${formatFindingsStatsLine(filtered)})`); - line(`使用量: ${formatUsageStatsLine(provider, model, runUsage, quota, rate)}`); + line(`使用量: ${formatUsageStatsLine(provider, modelLabel, runUsage, quota, rate)}`); try { await postFindingsReview(filtered, { summaryFindings: filtered, commentFindings: filtered, usageSection }); output('Gitea Review 已發布'); diff --git a/src/preflight.js b/src/preflight.js index 8ac8cea..3b2343c 100644 --- a/src/preflight.js +++ b/src/preflight.js @@ -165,29 +165,25 @@ export async function fetchLLMModels({ /** * 驗證 LLM proxy 設定可用。 * - * 確認目前環境可偵測到 CLIProxyAPI 且已解析出 model;額外向模型清單端點確認 - * proxy 可連線且設定的 model 在可用清單內(不送 prompt)。 + * 確認目前環境可偵測到 CLIProxyAPI;若有明確指定 model,則額外向模型清單端點確認 + * 該 model 在可用清單內(不送 prompt)。當 model 未指定時,只要求 proxy 與模型清單端點可連線。 * @param {object} [deps] - 可注入相依,供測試。 * @param {Function} [deps.fetchLLMModelsFn=fetchLLMModels] - proxy 模型清單取得函式。 * @returns {Promise< - * {ok: true, provider: string, command: null, model: string, models?: string[]} | - * {ok: false, provider?: string, command?: null, model?: string, error: string} + * {ok: true, provider: string, command: null, model: string|null, models?: string[]} | + * {ok: false, provider?: string, command?: null, model?: string|null, error: string} * >} * 通過時含 provider、command、model(另含 models 清單);未設定 provider 的失敗分支不含 provider。 * @remarks 設定來源為 config.js 的 getLLMConfig()。 - * @remarks 【需人工確認】依目前 getLLMConfig() 的型別標註(`provider: ('cliproxyapi'|null)`), - * `provider` 存在但不是 `'cliproxyapi'` 的分支在目前設定來源下應為不會被觸發的保留分支, - * 但無法從本檔案確認這是刻意保留的向前相容設計、還是尚未清理的死碼,建議與維護者確認。 */ export async function verifyLLM({ fetchLLMModelsFn = fetchLLMModels } = {}) { const { provider, command, model } = getLLMConfig(); if (!provider) return { ok: false, error: '未偵測到可用的 CLIProxyAPI 設定,請確認 CLI_PROXY_API' }; - if (!model) return { ok: false, provider, error: '未設定 MODEL' }; if (provider === 'cliproxyapi') { const models = await fetchLLMModelsFn(); if (!models.ok) return { ok: false, provider, command, model, error: models.error }; - if (!models.slugs.includes(model)) { + if (model && !models.slugs.includes(model)) { return { ok: false, provider, command, model, error: `模型 ${model} 不在 CLIProxyAPI 可用清單: [${models.slugs.join(', ')}]` }; } return { ok: true, provider, command, model, models: models.slugs }; @@ -255,7 +251,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || ' error(`LLM 驗證失敗: ${llm.error}`); return false; } - ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model})`); + ok(`LLM proxy 可用(provider=${llm.provider}, model=${llm.model || 'auto'})`); if (llm.models) line(`模型已確認在可用清單內(共 ${llm.models.length} 個可用模型)`); result(true, '前置驗證通過'); diff --git a/src/test/config.test.js b/src/test/config.test.js index b31f44f..949cf1c 100644 --- a/src/test/config.test.js +++ b/src/test/config.test.js @@ -3,8 +3,8 @@ import assert from 'node:assert/strict'; import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js'; const ENV_KEYS = [ - 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', - 'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL', + 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', 'INPUT_MODEL', + 'MODEL', 'OPENCODE_MODEL', ]; let saved = {}; @@ -52,6 +52,16 @@ describe('getLLMConfig', () => { assert.equal(cfg.model, 'gpt-5-mini'); }); + it('uses CLI_PROXY_API_MODEL when INPUT_MODEL is missing', () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.CLI_PROXY_API_MODEL = 'gpt-5.4-mini'; + process.env.MODEL = 'gpt-5.5'; + + const cfg = getLLMConfig(); + + assert.equal(cfg.model, 'gpt-5.4-mini'); + }); + it('returns null provider when CLI_PROXY_API is missing', () => { process.env.MODEL = 'gpt-5.5'; const cfg = getLLMConfig(); diff --git a/src/test/llm.test.js b/src/test/llm.test.js index 8c17a57..3251240 100644 --- a/src/test/llm.test.js +++ b/src/test/llm.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js'; const ENV_KEYS = [ - 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL', + 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER', ]; @@ -57,6 +57,25 @@ describe('chat - CLIProxyAPI', async () => { assert.equal(capturedOpts.headers.Authorization, 'Bearer secret'); }); + it('omits model from the request body when auto selection is allowed', async () => { + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.CLI_PROXY_API_KEY = 'secret'; + + let capturedBody; + mock.method(axios, 'post', async (url, body) => { + capturedBody = body; + return { + data: { choices: [{ message: { content: 'cli response' } }] }, + headers: {}, + }; + }); + + const result = await chat('sys', 'user'); + + assert.equal(result, 'cli response'); + assert.equal(Object.hasOwn(capturedBody, 'model'), false); + }); + it('throws an error when the API fails', async () => { process.env.CLI_PROXY_API = 'https://proxy.example'; process.env.MODEL = 'gpt-5-mini'; diff --git a/src/test/main.test.js b/src/test/main.test.js index 71acef1..4135aa5 100644 --- a/src/test/main.test.js +++ b/src/test/main.test.js @@ -131,6 +131,12 @@ describe('main pipeline', () => { assert.equal(await runMain(), 0); }); + it('無 MODEL 仍可由 Proxy 自動選模並正常走完(exit 0)', async () => { + assert.equal(await runMain({ + config: { getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: null, command: null }) }, + }), 0); + }); + it('clone 失敗仍繼續、不因 commitAndPush 中斷(無 critical → exit 0)', async () => { assert.equal(await runMain({ git: { cloneRepo: () => { throw new Error('clone fail'); } } }), 0); }); diff --git a/src/test/preflight.test.js b/src/test/preflight.test.js index 4e2361c..53102f0 100644 --- a/src/test/preflight.test.js +++ b/src/test/preflight.test.js @@ -4,7 +4,7 @@ import axios from 'axios'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js'; const LLM_ENV_KEYS = [ - 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', + 'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY', 'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL', ]; @@ -164,6 +164,22 @@ describe('verifyLLM', () => { assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']); }); + it('passes when no model is specified and the proxy is reachable', async () => { + clearLLMEnv(); + process.env.CLI_PROXY_API = 'https://proxy.example'; + process.env.CLI_PROXY_API_KEY = 'secret'; + + 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, null); + 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';