diff --git a/.gitea/workflows/review.yaml b/.gitea/workflows/review.yaml index b2ca93a..6fbcb0b 100644 --- a/.gitea/workflows/review.yaml +++ b/.gitea/workflows/review.yaml @@ -33,7 +33,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@v${{ needs.version.outputs.version }} with: - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY_1 }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }} GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} permissions: diff --git a/README.md b/README.md index dd5d383..745001c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ 3. Comment 加上些許 emoji 讓資訊有點活力 4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行 5. 將提示詞放到 ./app/prompts 內供程式讀取 +6. API Key 支援逗號分隔傳入多個,依序嘗試,失敗時自動換下一個,全部失敗則 exit 1 # 使用說明 @@ -42,7 +43,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # 支援逗號分隔多個 Key OPENAI_BASE_URL: https://api.openai.com/v1 OPENAI_MODEL: ${{ vars.OPENAI_MODEL }} permissions: @@ -65,7 +66,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # OpenRouter 使用 OpenAI 相容介面,以 OPENAI_API_KEY 傳入 + OPENAI_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} # OpenRouter 使用 OpenAI 相容介面,以 OPENAI_API_KEY 傳入,支援逗號分隔多個 Key OPENAI_BASE_URL: https://openrouter.ai/api/v1 OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }} permissions: @@ -88,7 +89,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} + CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key CLAUDE_BASE_URL: https://api.anthropic.com/v1 permissions: contents: write @@ -110,7 +111,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }},${{ secrets.GEMINI_API_KEY_1 }},${{ secrets.GEMINI_API_KEY_2 }},${{ secrets.GEMINI_API_KEY_3 }},${{ secrets.GEMINI_API_KEY_4 }},${{ secrets.GEMINI_API_KEY_5 }},${{ secrets.GEMINI_API_KEY_6 }},${{ secrets.GEMINI_API_KEY_7 }},${{ secrets.GEMINI_API_KEY_8 }},${{ secrets.GEMINI_API_KEY_9 }} GEMINI_BASE_URL: https://generativelanguage.googleapis.com/v1beta GEMINI_MODEL: ${{ vars.GEMINI_MODEL }} permissions: @@ -133,7 +134,7 @@ jobs: - name: AI Code Review uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }} with: - AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} + AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key AMAZONQ_BASE_URL: https://q.api.aws permissions: contents: write diff --git a/TODO.md b/TODO.md index 540a5c0..c605eac 100644 --- a/TODO.md +++ b/TODO.md @@ -35,6 +35,11 @@ - 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。 - 完成 +## 階段八:API Key 輪替 +- 目標:所有平台的 API Key 支援逗號分隔傳入多個,依序嘗試,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。 +- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。 +- 完成 + --- 所有階段驗收通過。 diff --git a/app/config.js b/app/config.js index 3dc350e..c8212e3 100644 --- a/app/config.js +++ b/app/config.js @@ -8,16 +8,22 @@ export const PR_BASE_BRANCH = process.env.PR_BASE_BRANCH || ''; export const FINDINGS_PATH = '.gitea/ai-review/findings.json'; export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.json'; +/** 將逗號分隔的 API key 字串拆成陣列 */ +function splitKeys(value) { + if (!value) return []; + return value.split(',').map(k => k.trim()).filter(Boolean); +} + export function getLLMConfig() { const checks = [ - ['openai', process.env.OPENAI_API_KEY, process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', process.env.OPENAI_MODEL || 'gpt-4o-mini'], - ['claude', process.env.CLAUDE_API_KEY, process.env.CLAUDE_BASE_URL || 'https://api.anthropic.com/v1', process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307'], - ['gemini', process.env.GEMINI_API_KEY, process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'], - ['ollama', 'ollama', process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL], - ['amazonq', process.env.AMAZONQ_API_KEY, process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'], + ['openai', splitKeys(process.env.OPENAI_API_KEY), process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1', process.env.OPENAI_MODEL || 'gpt-4o-mini'], + ['claude', splitKeys(process.env.CLAUDE_API_KEY), process.env.CLAUDE_BASE_URL || 'https://api.anthropic.com/v1', process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307'], + ['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'], + ['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL], + ['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'], ]; - for (const [provider, key, baseURL, model] of checks) { - if (key && baseURL) return { provider, apiKey: key, baseURL, model }; + for (const [provider, apiKeys, baseURL, model] of checks) { + if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model }; } - return { provider: null, apiKey: null, baseURL: null, model: null }; + return { provider: null, apiKeys: [], baseURL: null, model: null }; } diff --git a/app/config.test.js b/app/config.test.js index b757cec..03cf9ac 100644 --- a/app/config.test.js +++ b/app/config.test.js @@ -26,14 +26,14 @@ describe('getLLMConfig', () => { it('returns null provider when no env vars set', () => { const cfg = getLLMConfig(); assert.equal(cfg.provider, null); - assert.equal(cfg.apiKey, null); + assert.deepEqual(cfg.apiKeys, []); }); it('detects openai with defaults', () => { process.env.OPENAI_API_KEY = 'sk-test'; const cfg = getLLMConfig(); assert.equal(cfg.provider, 'openai'); - assert.equal(cfg.apiKey, 'sk-test'); + assert.deepEqual(cfg.apiKeys, ['sk-test']); assert.equal(cfg.baseURL, 'https://api.openai.com/v1'); assert.equal(cfg.model, 'gpt-4o-mini'); }); @@ -48,7 +48,14 @@ describe('getLLMConfig', () => { assert.equal(cfg.model, 'gpt-4o'); }); - it('detects gemini with defaults', () => { + it('detects gemini with comma-separated keys, picks one', () => { + process.env.GEMINI_API_KEY = 'key1,key2,key3'; + const cfg = getLLMConfig(); + assert.equal(cfg.provider, 'gemini'); + assert.deepEqual(cfg.apiKeys, ['key1', 'key2', 'key3']); + }); + + it('detects gemini with single key (no comma)', () => { process.env.GEMINI_API_KEY = 'gemini-key'; const cfg = getLLMConfig(); assert.equal(cfg.provider, 'gemini'); diff --git a/app/llm.js b/app/llm.js index db7217b..dddce5f 100644 --- a/app/llm.js +++ b/app/llm.js @@ -5,23 +5,31 @@ import { getLLMConfig } from './config.js'; const httpsAgent = new https.Agent({ rejectUnauthorized: false }); export async function chat(systemPrompt, userContent) { - const { provider, apiKey, baseURL, model } = getLLMConfig(); + const { provider, apiKeys, baseURL, model } = getLLMConfig(); if (!provider) throw new Error('未設定任何 LLM API Key'); console.log(` [LLM] provider=${provider} model=${model}`); - const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${apiKey}`, - }; + const headers = { 'Content-Type': 'application/json' }; if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; - const resp = await axios.post( - `${baseURL.replace(/\/$/, '')}/chat/completions`, - { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 }, - { headers, timeout: 120000, httpsAgent } - ); - return resp.data.choices[0].message.content; + let lastError; + for (let i = 0; i < apiKeys.length; i++) { + headers['Authorization'] = `Bearer ${apiKeys[i]}`; + try { + const resp = await axios.post( + `${baseURL.replace(/\/$/, '')}/chat/completions`, + { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 }, + { headers, timeout: 120000, httpsAgent } + ); + return resp.data.choices[0].message.content; + } catch (e) { + lastError = e; + console.log(` [LLM] key[${i + 1}/${apiKeys.length}] 失敗: ${e.message}`); + } + } + console.error(' [LLM] 所有 API Key 均失敗,終止流程'); + process.exit(1); } export async function chatJSON(systemPrompt, userContent) {