diff --git a/ .gitea / workflows/cd.yaml b/.gitea/ workflows/cd.yaml similarity index 100% rename from .gitea / workflows/cd.yaml rename to .gitea/ workflows/cd.yaml diff --git a/ .gitea / workflows/ci.yaml b/.gitea/ workflows/ci.yaml similarity index 100% rename from .gitea / workflows/ci.yaml rename to .gitea/ workflows/ci.yaml diff --git a/action.yaml b/action.yaml index cbb4e97..2574f87 100644 --- a/action.yaml +++ b/action.yaml @@ -1,136 +1,28 @@ -name: 'Docker Action Template' -description: 'Docker Action 範本' +name: 'AI Code Review' +description: 'AI Code Review' author: 'Jeffery' inputs: - # Gitea 相關(可從 gitea context 自動取得) - GITEA_TOKEN: - description: 'Gitea API Token' - required: true - GITEA_COMMENT_TOKEN: - description: 'Gitea API Token for posting comments only' - required: false - GITEA_SERVER_URL: - description: 'Gitea Server URL' - required: false - GITEA_REPOSITORY: - description: 'Gitea Repository (owner/repo)' - required: false - GITEA_SKIP_TLS_VERIFY: - description: '跳過 Gitea SSL/TLS 憑證驗證(自簽憑證時使用)' - required: false - default: 'false' - PR_NUMBER: - description: 'Pull Request Number' - required: false - PR_HEAD_BRANCH: - description: 'PR 來源分支' - required: false - PR_BASE_BRANCH: - description: 'PR 目標分支' - required: false - - # OpenAI-compatible - OPENAI_API_KEY: - description: 'OpenAI / OpenRouter API Key' - required: false - OPENAI_BASE_URL: - description: 'OpenAI-compatible Base URL' - required: false - default: 'https://openrouter.ai/api/v1' - OPENAI_MODEL: - description: 'OpenAI-compatible Model Name' - required: false - - # Anthropic Claude - CLAUDE_API_KEY: - description: 'Anthropic Claude API Key' - required: false - CLAUDE_BASE_URL: - description: 'Claude Base URL' - required: false - CLAUDE_MODEL: - description: 'Claude Model Name' - required: false - - # Google Gemini - GEMINI_API_KEY: - description: 'Google Gemini API Key' - required: false - GEMINI_BASE_URL: - description: 'Gemini Base URL' - required: false - GEMINI_MODEL: - description: 'Gemini Model Name' - required: false - - # Ollama - OLLAMA_BASE_URL: - description: 'Ollama Base URL' - required: false - OLLAMA_MODEL: - description: 'Ollama Model Name' - required: false - - # Amazon Q - AMAZONQ_API_KEY: - description: 'Amazon Q API Key' - required: false - AMAZONQ_BASE_URL: - description: 'Amazon Q Base URL' - required: false - - # OpenCode Server - OPENCODE_BASE_URL: + opencode_base_url: description: 'OpenCode server Base URL' required: false - OPENCODE_MODEL: + opencode_model: description: 'OpenCode model id' required: false - OPENCODE_PROVIDER: + opencode_provider: description: 'OpenCode server provider id' required: false - OPENCODE_SERVER_USERNAME: - description: 'OpenCode server Basic Auth username' - required: false - OPENCODE_SERVER_PASSWORD: - description: 'OpenCode server Basic Auth password' - required: false - OPENCODE_SKIP_TLS_VERIFY: - description: '跳過 OpenCode server SSL/TLS 憑證驗證' - required: false - default: 'true' - runs: using: 'docker' image: 'Dockerfile' env: - # Gitea context(改為只從 inputs 取得) - GITEA_TOKEN: ${{ inputs.GITEA_TOKEN }} - GITEA_COMMENT_TOKEN: ${{ inputs.GITEA_COMMENT_TOKEN }} - GITEA_SERVER_URL: ${{ inputs.GITEA_SERVER_URL || gitea.server_url }} - GITEA_REPOSITORY: ${{ inputs.GITEA_REPOSITORY || gitea.repository }} - GITEA_SKIP_TLS_VERIFY: ${{ inputs.GITEA_SKIP_TLS_VERIFY }} - PR_NUMBER: ${{ inputs.PR_NUMBER || gitea.event.pull_request.number }} - PR_HEAD_SHA: ${{ inputs.PR_HEAD_SHA || gitea.event.pull_request.head.sha }} - PR_HEAD_BRANCH: ${{ inputs.PR_HEAD_BRANCH || gitea.event.pull_request.head.ref }} - PR_BASE_BRANCH: ${{ inputs.PR_BASE_BRANCH || gitea.event.pull_request.base.ref }} - # LLM - OPENAI_API_KEY: ${{ inputs.OPENAI_API_KEY }} - OPENAI_BASE_URL: ${{ inputs.OPENAI_BASE_URL }} - OPENAI_MODEL: ${{ inputs.OPENAI_MODEL }} - CLAUDE_API_KEY: ${{ inputs.CLAUDE_API_KEY }} - CLAUDE_BASE_URL: ${{ inputs.CLAUDE_BASE_URL }} - CLAUDE_MODEL: ${{ inputs.CLAUDE_MODEL }} - GEMINI_API_KEY: ${{ inputs.GEMINI_API_KEY }} - GEMINI_BASE_URL: ${{ inputs.GEMINI_BASE_URL }} - GEMINI_MODEL: ${{ inputs.GEMINI_MODEL }} - OLLAMA_BASE_URL: ${{ inputs.OLLAMA_BASE_URL }} - OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }} - AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }} - AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }} - OPENCODE_BASE_URL: ${{ inputs.OPENCODE_BASE_URL }} - OPENCODE_MODEL: ${{ inputs.OPENCODE_MODEL }} - OPENCODE_PROVIDER: ${{ inputs.OPENCODE_PROVIDER }} - OPENCODE_SERVER_USERNAME: ${{ inputs.OPENCODE_SERVER_USERNAME }} - OPENCODE_SERVER_PASSWORD: ${{ inputs.OPENCODE_SERVER_PASSWORD }} - OPENCODE_SKIP_TLS_VERIFY: ${{ inputs.OPENCODE_SKIP_TLS_VERIFY }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ gitea.token }} + GITEA_COMMENT_TOKEN: ${{ gitea.token }} + PR_NUMBER: ${{ gitea.event.pull_request.number }} + PR_HEAD_SHA: ${{ gitea.event.pull_request.head.sha }} + PR_HEAD_BRANCH: ${{ gitea.event.pull_request.head.ref }} + PR_BASE_BRANCH: ${{ gitea.event.pull_request.base.ref }} + OPENCODE_BASE_URL: ${{ inputs.opencode_base_url }} + OPENCODE_MODEL: ${{ inputs.opencode_model }} + OPENCODE_PROVIDER: ${{ inputs.opencode_provider }} diff --git a/app/config.js b/app/config.js index da8e864..7c82513 100644 --- a/app/config.js +++ b/app/config.js @@ -4,7 +4,6 @@ export const GITEA_TOKEN = process.env.GITEA_TOKEN || ''; export const GITEA_COMMENT_TOKEN = process.env.GITEA_COMMENT_TOKEN || ''; export const GITEA_SERVER_URL = process.env.GITEA_SERVER_URL || 'https://gitea.com'; export const GITEA_REPOSITORY = process.env.GITEA_REPOSITORY || ''; -export const GITEA_SKIP_TLS_VERIFY = process.env.GITEA_SKIP_TLS_VERIFY === 'true'; export const PR_NUMBER = process.env.PR_NUMBER || ''; export const PR_HEAD_SHA = process.env.PR_HEAD_SHA || ''; export const PR_HEAD_BRANCH = process.env.PR_HEAD_BRANCH || ''; @@ -13,31 +12,18 @@ 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'; -export function shouldSkipOpenCodeTLSVerify() { - return process.env.OPENCODE_SKIP_TLS_VERIFY !== 'false'; -} - export function getOpenCodeHttpsAgent() { - return shouldSkipOpenCodeTLSVerify() ? new https.Agent({ rejectUnauthorized: false }) : undefined; -} - -/** 將逗號分隔的 API key 字串拆成陣列 */ -function splitKeys(value) { - if (!value) return []; - return value.split(',').map(k => k.trim()).filter(Boolean); + return new https.Agent({ rejectUnauthorized: false }); } export function getLLMConfig() { - const checks = [ - ['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'], - ['opencode', ['opencode'], process.env.OPENCODE_BASE_URL, process.env.OPENCODE_MODEL || 'gemini-2.5-flash'], - ]; - for (const [provider, apiKeys, baseURL, model] of checks) { - if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model }; + if (process.env.OPENCODE_BASE_URL) { + return { + provider: 'opencode', + apiKeys: ['opencode'], + baseURL: process.env.OPENCODE_BASE_URL, + model: process.env.OPENCODE_MODEL || 'gemini-2.5-flash', + }; } return { provider: null, apiKeys: [], baseURL: null, model: null }; } diff --git a/app/config.test.js b/app/config.test.js index 18547c0..13d6f67 100644 --- a/app/config.test.js +++ b/app/config.test.js @@ -1,16 +1,9 @@ import { describe, it, beforeEach, afterEach } from 'node:test'; import assert from 'node:assert/strict'; -import { getLLMConfig, shouldSkipOpenCodeTLSVerify } from './config.js'; +import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; const ENV_KEYS = [ - 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', - 'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL', - 'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', - 'OLLAMA_BASE_URL', 'OLLAMA_MODEL', - 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', - 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD', - 'OPENCODE_SKIP_TLS_VERIFY', ]; let saved = {}; @@ -32,62 +25,7 @@ describe('getLLMConfig', () => { 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.deepEqual(cfg.apiKeys, ['sk-test']); - assert.equal(cfg.baseURL, 'https://api.openai.com/v1'); - assert.equal(cfg.model, 'gpt-4o-mini'); - }); - - it('detects openai with custom base url and model', () => { - process.env.OPENAI_API_KEY = 'sk-test'; - process.env.OPENAI_BASE_URL = 'https://openrouter.ai/api/v1'; - process.env.OPENAI_MODEL = 'gpt-4o'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'openai'); - assert.equal(cfg.baseURL, 'https://openrouter.ai/api/v1'); - assert.equal(cfg.model, 'gpt-4o'); - }); - - 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'); - assert.equal(cfg.model, 'gemini-2.5-flash'); - }); - - it('detects gemini with custom model', () => { - process.env.GEMINI_API_KEY = 'gemini-key'; - process.env.GEMINI_MODEL = 'gemini-2.0-flash'; - const cfg = getLLMConfig(); - assert.equal(cfg.model, 'gemini-2.0-flash'); - }); - - it('detects claude with defaults', () => { - process.env.CLAUDE_API_KEY = 'claude-key'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'claude'); - assert.equal(cfg.model, 'claude-3-haiku-20240307'); - }); - - it('detects amazonq with its own model env', () => { - process.env.AMAZONQ_API_KEY = 'aq-key'; - process.env.AMAZONQ_MODEL = 'my-amazon-model'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'amazonq'); - assert.equal(cfg.model, 'my-amazon-model'); - }); - - it('detects opencode server with gemini defaults', () => { + it('detects opencode server with defaults', () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; const cfg = getLLMConfig(); assert.equal(cfg.provider, 'opencode'); @@ -105,48 +43,9 @@ describe('getLLMConfig', () => { assert.equal(cfg.model, 'google/gemini-2.5-pro'); }); - it('skips OpenCode TLS verification by default', () => { - assert.equal(shouldSkipOpenCodeTLSVerify(), true); + it('uses an insecure HTTPS agent for OpenCode', () => { + const agent = getOpenCodeHttpsAgent(); + assert.equal(agent.options.rejectUnauthorized, false); }); - it('allows explicitly enabling OpenCode TLS verification', () => { - process.env.OPENCODE_SKIP_TLS_VERIFY = 'false'; - assert.equal(shouldSkipOpenCodeTLSVerify(), false); - }); - - it('skips OpenCode TLS verification for empty string and non-false values', () => { - for (const value of ['', '0', 'true', 'yes', '1', 'on', 'custom']) { - process.env.OPENCODE_SKIP_TLS_VERIFY = value; - assert.equal(shouldSkipOpenCodeTLSVerify(), true); - } - }); - - it('openai takes priority over gemini when both set', () => { - process.env.OPENAI_API_KEY = 'sk-test'; - process.env.GEMINI_API_KEY = 'gemini-key'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'openai'); - }); - - it('empty string api key is treated as not set', () => { - process.env.OPENAI_API_KEY = ''; - process.env.GEMINI_API_KEY = 'gemini-key'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'gemini'); - }); - - it('detects ollama without api key', () => { - process.env.OLLAMA_BASE_URL = 'http://localhost:11434'; - process.env.OLLAMA_MODEL = 'llama3'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, 'ollama'); - assert.equal(cfg.model, 'llama3'); - }); - - it('comma-only api key is treated as not set', () => { - process.env.OPENAI_API_KEY = ',,,'; - const cfg = getLLMConfig(); - assert.equal(cfg.provider, null); - assert.deepEqual(cfg.apiKeys, []); - }); }); diff --git a/app/gitea.js b/app/gitea.js index 7e5dd88..0750ba6 100644 --- a/app/gitea.js +++ b/app/gitea.js @@ -1,9 +1,9 @@ import axios from 'axios'; import https from 'https'; -import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, GITEA_SKIP_TLS_VERIFY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js'; +import { GITEA_TOKEN, GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, PR_NUMBER, PR_HEAD_SHA, PR_HEAD_BRANCH } from './config.js'; import { line, warn } from './log.js'; -const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; +const httpsAgent = new https.Agent({ rejectUnauthorized: false }); const headers = (token = GITEA_TOKEN) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; diff --git a/app/gitea.test.js b/app/gitea.test.js index 46b67cd..5a2d263 100644 --- a/app/gitea.test.js +++ b/app/gitea.test.js @@ -37,14 +37,14 @@ describe('gitea', () => { assert.ok(capturedOpts.headers['Authorization'].startsWith('token ')); }); - it('does not set httpsAgent by default (GITEA_SKIP_TLS_VERIFY not true)', async () => { + it('sets an insecure httpsAgent by default', async () => { let capturedOpts; mock.method(axios, 'get', async (_url, opts) => { capturedOpts = opts; return { data: '' }; }); await getPRDiff(); - assert.equal(capturedOpts.httpsAgent, undefined); + assert.equal(capturedOpts.httpsAgent.options.rejectUnauthorized, false); }); it('getPRDiff propagates axios errors', async () => { diff --git a/app/llm.js b/app/llm.js index 44e1952..dcc7dc9 100644 --- a/app/llm.js +++ b/app/llm.js @@ -1,52 +1,13 @@ import axios from 'axios'; import { getLLMConfig, getOpenCodeHttpsAgent } from './config.js'; -import { recordUsage, recordRateLimit } from './usage.js'; +import { recordUsage } from './usage.js'; import { line, error } from './log.js'; -function isOpenAIGpt55(provider, model) { - return provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || ''); -} - -function chatEndpoint(baseURL, provider, model) { - const base = baseURL.replace(/\/$/, ''); - return isOpenAIGpt55(provider, model) ? `${base}/responses` : `${base}/chat/completions`; -} - -function chatPayload(provider, model, systemPrompt, userContent) { - if (isOpenAIGpt55(provider, model)) { - return { model, instructions: systemPrompt, input: userContent, temperature: 0.2 }; - } - return { model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 }; -} - -function extractContent(provider, model, data) { - if (!isOpenAIGpt55(provider, model)) return data.choices[0].message.content; - if (typeof data.output_text === 'string') return data.output_text; - const parts = data.output?.flatMap(item => item.content || []) || []; - const text = parts - .map(part => { - if (typeof part.text === 'string') return part.text; - if (typeof part.content === 'string') return part.content; - return ''; - }) - .filter(Boolean) - .join(''); - if (text) return text; - return data.choices?.[0]?.message?.content || ''; -} - function opencodeModelConfig(model) { const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; return { providerID, modelID }; } -function applyOpenCodeAuth(headers) { - const password = process.env.OPENCODE_SERVER_PASSWORD; - if (!password) return; - const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'; - headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; -} - function opencodeAxiosOptions(headers) { return { headers, @@ -86,37 +47,21 @@ async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) } export async function chat(systemPrompt, userContent) { - const { provider, apiKeys, baseURL, model } = getLLMConfig(); - if (!provider) throw new Error('未設定任何 LLM API Key'); + const { provider, baseURL, model } = getLLMConfig(); + if (!provider) throw new Error('未設定 OpenCode server,請設定 OPENCODE_BASE_URL'); line(`[LLM] provider=${provider} model=${model}`); const headers = { 'Content-Type': 'application/json' }; - if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; - const shuffled = [...apiKeys].sort(() => Math.random() - 0.5); - for (let i = 0; i < shuffled.length; i++) { - if (provider !== 'ollama' && provider !== 'opencode') headers['Authorization'] = `Bearer ${shuffled[i]}`; - try { - if (provider === 'opencode') { - applyOpenCodeAuth(headers); - const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); - recordUsage(data); - return content; - } - const resp = await axios.post( - chatEndpoint(baseURL, provider, model), - chatPayload(provider, model, systemPrompt, userContent), - { headers } - ); - recordUsage(resp.data); - recordRateLimit(resp.headers); - return extractContent(provider, model, resp.data); - } catch (e) { - line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`); - } + try { + const { content, data } = await chatOpenCode(baseURL, model, systemPrompt, userContent, headers); + recordUsage(data); + return content; + } catch (e) { + line(`[LLM] OpenCode 呼叫失敗: ${e.message}`); } - error('[LLM] 所有 API Key 均失敗,終止流程'); + error('[LLM] OpenCode 呼叫失敗,終止流程'); process.exit(1); } diff --git a/app/llm.test.js b/app/llm.test.js index 6f2196c..6fa0d82 100644 --- a/app/llm.test.js +++ b/app/llm.test.js @@ -1,18 +1,9 @@ import { describe, it, beforeEach, afterEach, mock } from 'node:test'; import assert from 'node:assert/strict'; - -// Mock axios before importing llm.js import axios from 'axios'; const ENV_KEYS = [ - 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', - 'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', - 'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL', - 'OLLAMA_BASE_URL', 'OLLAMA_MODEL', - 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', - 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD', - 'OPENCODE_SKIP_TLS_VERIFY', ]; let saved = {}; @@ -28,108 +19,19 @@ afterEach(() => { mock.restoreAll(); }); -function mockAxiosPost(responses) { - let call = 0; +function mockOpenCodeResponse(content) { + let calls = 0; mock.method(axios, 'post', async () => { - const r = responses[call++] ?? responses[responses.length - 1]; - if (r instanceof Error) throw r; - return r; + calls += 1; + if (calls === 1) return { data: { id: 'ses_test' } }; + return { data: { parts: [{ type: 'text', text: content }] } }; }); } -function makeOkResponse(content = 'ok') { - return { data: { choices: [{ message: { content } }] } }; -} - -describe('chat - key rotation', async () => { +describe('chat - OpenCode', async () => { const { chat } = await import('./llm.js'); - it('succeeds on first key', async () => { - process.env.OPENAI_API_KEY = 'key1'; - mockAxiosPost([makeOkResponse('hello')]); - const result = await chat('sys', 'user'); - assert.equal(result, 'hello'); - }); - - it('shuffles keys and tries each exactly once', async () => { - process.env.OPENAI_API_KEY = 'key1,key2,key3'; - const usedKeys = []; - mock.method(axios, 'post', async (_url, _body, opts) => { - usedKeys.push(opts.headers['Authorization'].replace('Bearer ', '')); - throw new Error('fail'); - }); - const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); }); - await assert.rejects(() => chat('sys', 'user'), /exit:1/); - assert.equal(exitMock.mock.calls[0].arguments[0], 1); - assert.equal(usedKeys.length, 3); - assert.deepEqual([...usedKeys].sort(), ['key1', 'key2', 'key3']); - }); - - it('calls process.exit(1) when all keys fail', async () => { - process.env.OPENAI_API_KEY = 'k1,k2'; - mockAxiosPost([new Error('fail'), new Error('fail')]); - const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); }); - await assert.rejects(() => chat('sys', 'user'), /exit:1/); - assert.equal(exitMock.mock.calls[0].arguments[0], 1); - }); - - it('does not set Authorization header for ollama', async () => { - process.env.OLLAMA_BASE_URL = 'http://localhost:11434/v1'; - process.env.OLLAMA_MODEL = 'llama3'; - let capturedHeaders; - mock.method(axios, 'post', async (_url, _body, opts) => { - capturedHeaders = opts.headers; - return makeOkResponse('ollama response'); - }); - await chat('sys', 'user'); - assert.equal(capturedHeaders['Authorization'], undefined); - }); - - it('sets Authorization header for openai', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - let capturedHeaders; - mock.method(axios, 'post', async (_url, _body, opts) => { - capturedHeaders = opts.headers; - return makeOkResponse(); - }); - await chat('sys', 'user'); - assert.equal(capturedHeaders['Authorization'], 'Bearer sk-test'); - }); - - it('does not set timeout', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - let capturedOpts; - mock.method(axios, 'post', async (_url, _body, opts) => { - capturedOpts = opts; - return makeOkResponse(); - }); - await chat('sys', 'user'); - assert.equal(capturedOpts.timeout, undefined); - }); - - it('does not pass httpsAgent to axios', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - let capturedOpts; - mock.method(axios, 'post', async (_url, _body, opts) => { - capturedOpts = opts; - return makeOkResponse(); - }); - await chat('sys', 'user'); - assert.equal(capturedOpts.httpsAgent, undefined); - }); - - it('sets anthropic-version header for claude', async () => { - process.env.CLAUDE_API_KEY = 'claude-key'; - let capturedHeaders; - mock.method(axios, 'post', async (_url, _body, opts) => { - capturedHeaders = opts.headers; - return makeOkResponse(); - }); - await chat('sys', 'user'); - assert.equal(capturedHeaders['anthropic-version'], '2023-06-01'); - }); - - it('uses OpenCode server session API for opencode', async () => { + it('uses OpenCode server session API', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.OPENCODE_PROVIDER = 'google'; process.env.OPENCODE_MODEL = 'gemini-2.5-flash'; @@ -139,7 +41,9 @@ describe('chat - key rotation', async () => { if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: 'opencode response' }] } }; }); + const result = await chat('sys', 'user'); + assert.equal(result, 'opencode response'); assert.equal(calls[0].url, 'http://opencode.local:4096/session'); assert.deepEqual(calls[0].payload.model, { providerID: 'google', id: 'gemini-2.5-flash' }); @@ -150,20 +54,6 @@ describe('chat - key rotation', async () => { assert.equal(calls[1].headers['Authorization'], undefined); }); - it('uses Basic Auth for protected OpenCode server', async () => { - process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; - process.env.OPENCODE_SERVER_USERNAME = 'opencode'; - process.env.OPENCODE_SERVER_PASSWORD = 'secret'; - const headers = []; - mock.method(axios, 'post', async (url, _payload, opts) => { - headers.push(opts.headers); - if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; - return { data: { parts: [{ type: 'text', text: 'ok' }] } }; - }); - await chat('sys', 'user'); - assert.equal(headers[0]['Authorization'], `Basic ${Buffer.from('opencode:secret').toString('base64')}`); - }); - it('passes an insecure https agent to OpenCode by default', async () => { process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; const agents = []; @@ -172,41 +62,15 @@ describe('chat - key rotation', async () => { if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: 'ok' }] } }; }); + await chat('sys', 'user'); + assert.equal(agents.length, 2); assert.equal(agents[0].options.rejectUnauthorized, false); assert.equal(agents[1].options.rejectUnauthorized, false); }); - it('does not pass an insecure https agent to OpenCode when TLS verification is enabled', async () => { - process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; - process.env.OPENCODE_SKIP_TLS_VERIFY = 'false'; - const agents = []; - mock.method(axios, 'post', async (url, _payload, opts) => { - agents.push(opts.httpsAgent); - if (url.endsWith('/session')) return { data: { id: 'ses_test' } }; - return { data: { parts: [{ type: 'text', text: 'ok' }] } }; - }); - await chat('sys', 'user'); - assert.deepEqual(agents, [undefined, undefined]); - }); - - it('uses Responses API for openai GPT-5.5', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - process.env.OPENAI_MODEL = 'GPT-5.5'; - let capturedUrl, capturedPayload; - mock.method(axios, 'post', async (url, payload) => { - capturedUrl = url; - capturedPayload = payload; - return { data: { output_text: 'gpt response' } }; - }); - const result = await chat('sys', 'user'); - assert.equal(result, 'gpt response'); - assert.equal(capturedUrl, 'https://api.openai.com/v1/responses'); - assert.deepEqual(capturedPayload, { model: 'GPT-5.5', instructions: 'sys', input: 'user', temperature: 0.2 }); - }); - - it('extracts opencode text from message parts', async () => { + it('extracts text from OpenCode message parts', async () => { process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; let calls = 0; mock.method(axios, 'post', async () => { @@ -214,46 +78,68 @@ describe('chat - key rotation', async () => { if (calls === 1) return { data: { id: 'ses_test' } }; return { data: { parts: [{ type: 'text', text: 'hello' }, { type: 'text', text: ' world' }] } }; }); + const result = await chat('sys', 'user'); + assert.equal(result, 'hello world'); }); + + it('calls process.exit(1) when OpenCode fails', async () => { + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + mock.method(axios, 'post', async () => { throw new Error('fail'); }); + const exitMock = mock.method(process, 'exit', () => { throw new Error('exit:1'); }); + + await assert.rejects(() => chat('sys', 'user'), /exit:1/); + + assert.equal(exitMock.mock.calls[0].arguments[0], 1); + }); }); describe('chatJSON', async () => { const { chatJSON } = await import('./llm.js'); it('parses plain JSON response', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - mockAxiosPost([makeOkResponse('[{"level":"critical"}]')]); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + mockOpenCodeResponse('[{"level":"critical"}]'); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, [{ level: 'critical' }]); }); it('strips markdown code block before parsing', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - mockAxiosPost([makeOkResponse('```json\n[{"level":"info"}]\n```')]); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + mockOpenCodeResponse('```json\n[{"level":"info"}]\n```'); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, [{ level: 'info' }]); }); it('extracts JSON array from surrounding prose', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - mockAxiosPost([makeOkResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.')]); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + mockOpenCodeResponse('**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.'); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]); }); it('extracts JSON object from surrounding prose', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - mockAxiosPost([makeOkResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}')]); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + mockOpenCodeResponse('**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}'); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' }); }); it('returns [] when JSON is invalid', async () => { - process.env.OPENAI_API_KEY = 'sk-test'; - mockAxiosPost([makeOkResponse('not json')]); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + mockOpenCodeResponse('not json'); + const result = await chatJSON('sys', 'user'); + assert.deepEqual(result, []); }); }); diff --git a/app/package-lock.json b/app/package-lock.json index 6aaee51..58788b7 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,47 +9,7 @@ "version": "1.0.0", "dependencies": { "axios": "^1.6.7", - "js-yaml": "^4.1.0", - "openai": "^4.28.0" - } - }, - "node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" + "js-yaml": "^4.1.0" } }, "node_modules/argparse": { @@ -157,14 +117,6 @@ "node": ">= 0.4" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "engines": { - "node": ">=6" - } - }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -199,23 +151,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -306,14 +241,6 @@ "node": ">= 0.4" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -352,78 +279,6 @@ "node": ">= 0.6" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -431,38 +286,6 @@ "engines": { "node": ">=10" } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } } } } diff --git a/app/package.json b/app/package.json index b010617..08c6c22 100644 --- a/app/package.json +++ b/app/package.json @@ -7,7 +7,6 @@ }, "dependencies": { "axios": "^1.6.7", - "js-yaml": "^4.1.0", - "openai": "^4.28.0" + "js-yaml": "^4.1.0" } } diff --git a/app/preflight.js b/app/preflight.js index bf912c4..505526c 100644 --- a/app/preflight.js +++ b/app/preflight.js @@ -5,7 +5,6 @@ import { GITEA_COMMENT_TOKEN, GITEA_SERVER_URL, GITEA_REPOSITORY, - GITEA_SKIP_TLS_VERIFY, PR_NUMBER, getOpenCodeHttpsAgent, getLLMConfig, @@ -13,20 +12,13 @@ import { import { verifyRemoteAccess } from './git.js'; import { step, line, ok, error, result } from './log.js'; -const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined; +const httpsAgent = new https.Agent({ rejectUnauthorized: false }); const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`; const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' }); -const usesResponsesApi = (provider, model) => provider === 'openai' && /^gpt-5\.5(?:-|$)/i.test(model || ''); const opencodeModelConfig = (model) => { const [providerID, modelID] = model.includes('/') ? model.split('/', 2) : [process.env.OPENCODE_PROVIDER || 'google', model]; return { providerID, modelID }; }; -const applyOpenCodeAuth = (headers) => { - const password = process.env.OPENCODE_SERVER_PASSWORD; - if (!password) return; - const username = process.env.OPENCODE_SERVER_USERNAME || 'opencode'; - headers['Authorization'] = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; -}; const opencodeAxiosOptions = (headers) => ({ headers, timeout: 30000, @@ -70,58 +62,28 @@ export async function verifyCommentToken(token = GITEA_COMMENT_TOKEN) { /** * 驗證 LLM 設定可用: - * - 須已選定一個 provider - * - Ollama 檢查 base URL 是否可連線 - * - 其餘 provider 以最小請求驗證認證,多把 Key 只要一把成功即可 + * - 僅支援 OpenCode server + * - 檢查 OpenCode base URL 是否可連線,並確認 provider/model 已設定 */ export async function verifyLLM() { - const { provider, apiKeys, baseURL, model } = getLLMConfig(); - if (!provider) return { ok: false, error: '未設定任何 LLM provider 或 API Key' }; + const { provider, baseURL, model } = getLLMConfig(); + if (!provider) return { ok: false, error: '未設定 OpenCode server,請設定 OPENCODE_BASE_URL' }; if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` }; const base = baseURL.replace(/\/$/, ''); const headers = { 'Content-Type': 'application/json' }; - if (provider === 'ollama') { - try { - await axios.get(`${base}/models`, { timeout: 30000 }); - return { ok: true, provider }; - } catch (e) { - return { ok: false, provider, error: `Ollama base URL 無法連線: ${e.message}` }; - } + const { providerID, modelID } = opencodeModelConfig(model); + try { + await axios.get(`${base}/global/health`, opencodeAxiosOptions(headers)); + const providers = await axios.get(`${base}/config/providers`, opencodeAxiosOptions(headers)); + const configuredProvider = providers.data.providers?.find(p => p.id === providerID); + if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` }; + if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` }; + return { ok: true, provider }; + } catch (e) { + return { ok: false, provider, error: `OpenCode server 驗證失敗: ${e.message}` }; } - - if (provider === 'opencode') { - const { providerID, modelID } = opencodeModelConfig(model); - applyOpenCodeAuth(headers); - try { - await axios.get(`${base}/global/health`, opencodeAxiosOptions(headers)); - const providers = await axios.get(`${base}/config/providers`, opencodeAxiosOptions(headers)); - const configuredProvider = providers.data.providers?.find(p => p.id === providerID); - if (!configuredProvider) return { ok: false, provider, error: `OpenCode server 未設定 provider=${providerID}` }; - if (!configuredProvider.models?.[modelID]) return { ok: false, provider, error: `OpenCode server provider=${providerID} 未列出 model=${modelID}` }; - return { ok: true, provider }; - } catch (e) { - return { ok: false, provider, error: `OpenCode server 驗證失敗: ${e.message}` }; - } - } - - if (provider === 'claude') headers['anthropic-version'] = '2023-06-01'; - const endpoint = usesResponsesApi(provider, model) ? `${base}/responses` : `${base}/chat/completions`; - const payload = usesResponsesApi(provider, model) - ? { model, input: 'ping', max_output_tokens: 1, temperature: 0 } - : { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 }; - - for (let i = 0; i < apiKeys.length; i++) { - headers['Authorization'] = `Bearer ${apiKeys[i]}`; - try { - await axios.post(endpoint, payload, { headers, timeout: 30000 }); - return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length }; - } catch (e) { - line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`); - } - } - return { ok: false, provider, error: `所有 ${apiKeys.length} 把 ${provider} API Key 驗證失敗` }; } /** @@ -172,8 +134,7 @@ export async function runPreflight(workspace = process.env.GITHUB_WORKSPACE || ' error(`LLM 驗證失敗: ${llm.error}`); return false; } - if (llm.keyIndex) ok(`LLM provider=${llm.provider} 驗證通過(key ${llm.keyIndex}/${llm.total})`); - else ok(`LLM provider=${llm.provider} 連線正常`); + ok(`LLM provider=${llm.provider} 連線正常`); result(true, '前置驗證通過'); return true; diff --git a/app/preflight.test.js b/app/preflight.test.js index 76d642c..ba2e1c5 100644 --- a/app/preflight.test.js +++ b/app/preflight.test.js @@ -4,14 +4,7 @@ import axios from 'axios'; import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, runPreflight } from './preflight.js'; const LLM_ENV_KEYS = [ - 'OPENAI_API_KEY', 'OPENAI_BASE_URL', 'OPENAI_MODEL', - 'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL', - 'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL', - 'OLLAMA_BASE_URL', 'OLLAMA_MODEL', - 'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL', 'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER', - 'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD', - 'OPENCODE_SKIP_TLS_VERIFY', ]; function clearLLMEnv() { @@ -51,7 +44,9 @@ describe('verifyGiteaToken', () => { capturedOpts = opts; return { data: { full_name: 'owner/repo' } }; }); + const result = await verifyGiteaToken('tok', 'owner/repo'); + assert.equal(result.ok, true); assert.ok(capturedUrl.includes('/api/v1/repos/owner/repo')); assert.equal(capturedOpts.headers['Authorization'], 'token tok'); @@ -63,7 +58,9 @@ describe('verifyGiteaToken', () => { e.response = { status: 401 }; throw e; }); + const result = await verifyGiteaToken('bad', 'owner/repo'); + assert.equal(result.ok, false); assert.match(result.error, /HTTP 401/); }); @@ -82,7 +79,9 @@ describe('verifyCommentToken', () => { capturedOpts = opts; return { data: { login: 'bot' } }; }); + const result = await verifyCommentToken('ctok'); + assert.equal(result.ok, true); assert.ok(capturedUrl.endsWith('/api/v1/user')); assert.equal(capturedOpts.headers['Authorization'], 'token ctok'); @@ -94,79 +93,25 @@ describe('verifyCommentToken', () => { e.response = { status: 401 }; throw e; }); + const result = await verifyCommentToken('bad'); + assert.equal(result.ok, false); assert.match(result.error, /HTTP 401/); }); }); describe('verifyLLM', () => { - it('fails when no provider/key configured', async () => { + it('fails when OpenCode is not configured', async () => { clearLLMEnv(); + const result = await verifyLLM(); + assert.equal(result.ok, false); - assert.match(result.error, /未設定/); + assert.match(result.error, /OPENCODE_BASE_URL/); }); - it('ok when an OpenAI-compatible key authenticates', async () => { - clearLLMEnv(); - process.env.OPENAI_API_KEY = 'k1,k2'; - let capturedUrl, capturedPayload, capturedHeaders; - mock.method(axios, 'post', async (url, payload, opts) => { - capturedUrl = url; - capturedPayload = payload; - capturedHeaders = opts.headers; - return { data: { choices: [{ message: { content: 'ok' } }] } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.equal(result.provider, 'openai'); - assert.equal(result.keyIndex, 1); - assert.equal(result.total, 2); - assert.ok(capturedUrl.endsWith('/chat/completions')); - assert.equal(capturedPayload.max_tokens, 1); - assert.equal(capturedHeaders['Authorization'], 'Bearer k1'); - }); - - it('tries the next key when the first one fails', async () => { - clearLLMEnv(); - process.env.OPENAI_API_KEY = 'bad,good'; - let calls = 0; - mock.method(axios, 'post', async (_url, _payload, opts) => { - calls += 1; - if (opts.headers['Authorization'] === 'Bearer bad') throw new Error('401'); - return { data: { choices: [{ message: { content: 'ok' } }] } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.equal(result.keyIndex, 2); - assert.equal(calls, 2); - }); - - it('fails when all keys fail', async () => { - clearLLMEnv(); - process.env.OPENAI_API_KEY = 'k1,k2'; - mock.method(axios, 'post', async () => { throw new Error('401'); }); - const result = await verifyLLM(); - assert.equal(result.ok, false); - assert.match(result.error, /所有 2 把 openai API Key 驗證失敗/); - }); - - it('sets anthropic-version header for claude', async () => { - clearLLMEnv(); - process.env.CLAUDE_API_KEY = 'ck'; - let capturedHeaders; - mock.method(axios, 'post', async (_url, _payload, opts) => { - capturedHeaders = opts.headers; - return { data: { choices: [{ message: { content: 'ok' } }] } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.equal(result.provider, 'claude'); - assert.equal(capturedHeaders['anthropic-version'], '2023-06-01'); - }); - - it('checks opencode server provider and model', async () => { + it('checks OpenCode server provider and model', async () => { clearLLMEnv(); process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; process.env.OPENCODE_PROVIDER = 'google'; @@ -177,13 +122,46 @@ describe('verifyLLM', () => { if (url.endsWith('/global/health')) return { data: { healthy: true, version: '1.17.7' } }; return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; }); + const result = await verifyLLM(); + assert.equal(result.ok, true); assert.equal(result.provider, 'opencode'); assert.deepEqual(urls, ['http://opencode.local:4096/global/health', 'http://opencode.local:4096/config/providers']); }); - it('passes an insecure https agent for opencode by default', async () => { + it('fails when configured provider is missing', async () => { + clearLLMEnv(); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + process.env.OPENCODE_PROVIDER = 'google'; + mock.method(axios, 'get', async (url) => { + if (url.endsWith('/global/health')) return { data: { healthy: true } }; + return { data: { providers: [{ id: 'anthropic', models: {} }] } }; + }); + + const result = await verifyLLM(); + + assert.equal(result.ok, false); + assert.match(result.error, /未設定 provider=google/); + }); + + it('fails when configured model is missing', async () => { + clearLLMEnv(); + process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096'; + process.env.OPENCODE_PROVIDER = 'google'; + process.env.OPENCODE_MODEL = 'gemini-2.5-pro'; + mock.method(axios, 'get', async (url) => { + if (url.endsWith('/global/health')) return { data: { healthy: true } }; + return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; + }); + + const result = await verifyLLM(); + + assert.equal(result.ok, false); + assert.match(result.error, /未列出 model=gemini-2.5-pro/); + }); + + it('passes an insecure https agent by default', async () => { clearLLMEnv(); process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; const agents = []; @@ -192,106 +170,30 @@ describe('verifyLLM', () => { if (url.endsWith('/global/health')) return { data: { healthy: true } }; return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; }); + const result = await verifyLLM(); + assert.equal(result.ok, true); assert.equal(agents.length, 2); assert.equal(agents[0].options.rejectUnauthorized, false); assert.equal(agents[1].options.rejectUnauthorized, false); }); - it('passes an insecure https agent for opencode when TLS skip is any non-false value', async () => { - for (const value of ['true', '', '0', 'yes', '1', 'on']) { - clearLLMEnv(); - mock.restoreAll(); - process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; - process.env.OPENCODE_SKIP_TLS_VERIFY = value; - const agents = []; - mock.method(axios, 'get', async (url, opts) => { - agents.push(opts.httpsAgent); - if (url.endsWith('/global/health')) return { data: { healthy: true } }; - return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.equal(agents.length, 2); - assert.equal(agents[0].options.rejectUnauthorized, false); - assert.equal(agents[1].options.rejectUnauthorized, false); - } - }); - - it('does not pass an insecure https agent for opencode when TLS verification is enabled', async () => { - clearLLMEnv(); - process.env.OPENCODE_BASE_URL = 'https://opencode.local:4096'; - process.env.OPENCODE_SKIP_TLS_VERIFY = 'false'; - const agents = []; - mock.method(axios, 'get', async (url, opts) => { - agents.push(opts.httpsAgent); - if (url.endsWith('/global/health')) return { data: { healthy: true } }; - return { data: { providers: [{ id: 'google', models: { 'gemini-2.5-flash': { id: 'gemini-2.5-flash' } } }] } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.deepEqual(agents, [undefined, undefined]); - }); - - it('checks openai GPT-5.5 with Responses API', async () => { - clearLLMEnv(); - process.env.OPENAI_API_KEY = 'sk-test'; - process.env.OPENAI_MODEL = 'GPT-5.5'; - let capturedUrl, capturedPayload; - mock.method(axios, 'post', async (url, payload) => { - capturedUrl = url; - capturedPayload = payload; - return { data: { output_text: 'o' } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.equal(result.provider, 'openai'); - assert.equal(capturedUrl, 'https://api.openai.com/v1/responses'); - assert.equal(capturedPayload.model, 'GPT-5.5'); - assert.equal(capturedPayload.max_output_tokens, 1); - }); - - it('checks base URL connectivity for ollama (no key)', async () => { - clearLLMEnv(); - process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1'; - let capturedUrl; - mock.method(axios, 'get', async (url) => { - capturedUrl = url; - return { data: { data: [] } }; - }); - const result = await verifyLLM(); - assert.equal(result.ok, true); - assert.equal(result.provider, 'ollama'); - assert.ok(capturedUrl.endsWith('/models')); - }); - - it('fails when ollama base URL is unreachable', async () => { - clearLLMEnv(); - process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1'; - mock.method(axios, 'get', async () => { throw new Error('ECONNREFUSED'); }); - const result = await verifyLLM(); - assert.equal(result.ok, false); - assert.match(result.error, /無法連線/); - }); }); describe('runPreflight', () => { - // Stub deps that all succeed; individual tests override one to fail. function makeDeps(overrides = {}) { return { checkEnv: () => ({ ok: true, missing: [] }), verifyToken: async () => ({ ok: true }), verifyComment: async () => ({ ok: true }), verifyRemote: () => ({ ok: true }), - verifyLLMFn: async () => ({ ok: true, provider: 'openai', keyIndex: 1, total: 1 }), + verifyLLMFn: async () => ({ ok: true, provider: 'opencode' }), ...overrides, }; } it('returns false and stops early when required env is missing', async () => { - // Config constants default to empty in the test environment, so the - // required-env check fails before any network call is attempted. const result = await runPreflight(); assert.equal(result, false); }); @@ -337,7 +239,7 @@ describe('runPreflight', () => { it('returns false when LLM verification fails', async () => { const result = await runPreflight('/ws', makeDeps({ - verifyLLMFn: async () => ({ ok: false, error: '所有 key 驗證失敗' }), + verifyLLMFn: async () => ({ ok: false, error: 'OpenCode server 驗證失敗' }), })); assert.equal(result, false); });