feat: support multiple API keys for LLM providers, allowing automatic key rotation on failure
This commit is contained in:
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@v${{ needs.version.outputs.version }}
|
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@v${{ needs.version.outputs.version }}
|
||||||
with:
|
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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
3. Comment 加上些許 emoji 讓資訊有點活力
|
3. Comment 加上些許 emoji 讓資訊有點活力
|
||||||
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
4. 盡量將應用程式放在 ./app,修改 entrypoint.sh 與 Dockerfile 讓程式可以正常運行
|
||||||
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
5. 將提示詞放到 ./app/prompts 內供程式讀取
|
||||||
|
6. API Key 支援逗號分隔傳入多個,依序嘗試,失敗時自動換下一個,全部失敗則 exit 1
|
||||||
|
|
||||||
# 使用說明
|
# 使用說明
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
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_BASE_URL: https://api.openai.com/v1
|
||||||
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
OPENAI_MODEL: ${{ vars.OPENAI_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
@@ -65,7 +66,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
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_BASE_URL: https://openrouter.ai/api/v1
|
||||||
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
|
OPENAI_MODEL: ${{ vars.OPENROUTER_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
@@ -88,7 +89,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
|
CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
CLAUDE_BASE_URL: https://api.anthropic.com/v1
|
CLAUDE_BASE_URL: https://api.anthropic.com/v1
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -110,7 +111,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
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_BASE_URL: https://generativelanguage.googleapis.com/v1beta
|
||||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||||
permissions:
|
permissions:
|
||||||
@@ -133,7 +134,7 @@ jobs:
|
|||||||
- name: AI Code Review
|
- name: AI Code Review
|
||||||
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
uses: https://gitea.jsc.idv.tw/jiantw83/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
with:
|
with:
|
||||||
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }}
|
AMAZONQ_API_KEY: ${{ secrets.AMAZONQ_API_KEY }} # 支援逗號分隔多個 Key
|
||||||
AMAZONQ_BASE_URL: https://q.api.aws
|
AMAZONQ_BASE_URL: https://q.api.aws
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
- 驗收:log 中能看到「critical 問題存在,workflow 結束(exit 1)」等明確訊息,且 workflow 狀態為失敗。
|
||||||
- 完成
|
- 完成
|
||||||
|
|
||||||
|
## 階段八:API Key 輪替
|
||||||
|
- 目標:所有平台的 API Key 支援逗號分隔傳入多個,依序嘗試,單一 Key 失敗時自動換下一個,全部失敗則 exit 1。
|
||||||
|
- 驗收:log 中能看到「key[N/M] 失敗」等訊息,換 key 後繼續執行;傳入單一 Key 時行為與原本相同;全部 Key 失敗時 log「所有 API Key 均失敗,終止流程」且 workflow 狀態為失敗。
|
||||||
|
- 完成
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
所有階段驗收通過。
|
所有階段驗收通過。
|
||||||
|
|||||||
+14
-8
@@ -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 FINDINGS_PATH = '.gitea/ai-review/findings.json';
|
||||||
export const EXCLUSIONS_PATH = '.gitea/ai-review/exclusions.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() {
|
export function getLLMConfig() {
|
||||||
const checks = [
|
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'],
|
['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', process.env.CLAUDE_API_KEY, process.env.CLAUDE_BASE_URL || 'https://api.anthropic.com/v1', process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307'],
|
['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', process.env.GEMINI_API_KEY, process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'],
|
['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],
|
['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'],
|
['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) {
|
for (const [provider, apiKeys, baseURL, model] of checks) {
|
||||||
if (key && baseURL) return { provider, apiKey: key, baseURL, model };
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-3
@@ -26,14 +26,14 @@ describe('getLLMConfig', () => {
|
|||||||
it('returns null provider when no env vars set', () => {
|
it('returns null provider when no env vars set', () => {
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
assert.equal(cfg.provider, null);
|
assert.equal(cfg.provider, null);
|
||||||
assert.equal(cfg.apiKey, null);
|
assert.deepEqual(cfg.apiKeys, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects openai with defaults', () => {
|
it('detects openai with defaults', () => {
|
||||||
process.env.OPENAI_API_KEY = 'sk-test';
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
assert.equal(cfg.provider, 'openai');
|
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.baseURL, 'https://api.openai.com/v1');
|
||||||
assert.equal(cfg.model, 'gpt-4o-mini');
|
assert.equal(cfg.model, 'gpt-4o-mini');
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,14 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(cfg.model, 'gpt-4o');
|
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';
|
process.env.GEMINI_API_KEY = 'gemini-key';
|
||||||
const cfg = getLLMConfig();
|
const cfg = getLLMConfig();
|
||||||
assert.equal(cfg.provider, 'gemini');
|
assert.equal(cfg.provider, 'gemini');
|
||||||
|
|||||||
+19
-11
@@ -5,23 +5,31 @@ import { getLLMConfig } from './config.js';
|
|||||||
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
const httpsAgent = new https.Agent({ rejectUnauthorized: false });
|
||||||
|
|
||||||
export async function chat(systemPrompt, userContent) {
|
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');
|
if (!provider) throw new Error('未設定任何 LLM API Key');
|
||||||
|
|
||||||
console.log(` [LLM] provider=${provider} model=${model}`);
|
console.log(` [LLM] provider=${provider} model=${model}`);
|
||||||
|
|
||||||
const headers = {
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${apiKey}`,
|
|
||||||
};
|
|
||||||
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
||||||
|
|
||||||
const resp = await axios.post(
|
let lastError;
|
||||||
`${baseURL.replace(/\/$/, '')}/chat/completions`,
|
for (let i = 0; i < apiKeys.length; i++) {
|
||||||
{ model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 },
|
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
|
||||||
{ headers, timeout: 120000, httpsAgent }
|
try {
|
||||||
);
|
const resp = await axios.post(
|
||||||
return resp.data.choices[0].message.content;
|
`${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) {
|
export async function chatJSON(systemPrompt, userContent) {
|
||||||
|
|||||||
Reference in New Issue
Block a user