調整 OpenCode 與 Gitea action 設定 #3

Merged
admin merged 2 commits from develop into master 2026-06-25 10:30:15 +00:00
11 changed files with 162 additions and 837 deletions
Showing only changes of commit 65f27f544b - Show all commits
+27 -105
View File
@@ -1,136 +1,58 @@
name: 'Docker Action Template'
description: 'Docker Action 範本'
name: 'AI Code Review'
description: 'AI Code Review'
author: 'Jeffery'
inputs:
# Gitea 相關(可從 gitea context 自動取得)
GITEA_TOKEN:
# Gitea context
gitea_token:
description: 'Gitea API Token'
required: true
GITEA_COMMENT_TOKEN:
gitea_comment_token:
description: 'Gitea API Token for posting comments only'
required: false
GITEA_SERVER_URL:
gitea_server_url:
description: 'Gitea Server URL'
required: false
GITEA_REPOSITORY:
gitea_repository:
description: 'Gitea Repository (owner/repo)'
required: false
GITEA_SKIP_TLS_VERIFY:
gitea_skip_tls_verify:
description: '跳過 Gitea SSL/TLS 憑證驗證(自簽憑證時使用)'
required: false
default: 'false'
PR_NUMBER:
pr_number:
description: 'Pull Request Number'
required: false
PR_HEAD_BRANCH:
pr_head_sha:
description: 'PR head commit SHA'
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 }}
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 }}
# 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 }}
OPENCODE_BASE_URL: ${{ inputs.opencode_base_url }}
OPENCODE_MODEL: ${{ inputs.opencode_model }}
OPENCODE_PROVIDER: ${{ inputs.opencode_provider }}
+8 -21
View File
@@ -13,31 +13,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 };
}
+5 -106
View File
@@ -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, []);
});
});
+5 -60
View File
@@ -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}`);
line(`[LLM] OpenCode 呼叫失敗: ${e.message}`);
}
}
error('[LLM] 所有 API Key 均失敗,終止流程');
error('[LLM] OpenCode 呼叫失敗,終止流程');
process.exit(1);
}
+44 -158
View File
@@ -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, []);
});
});
+1 -178
View File
@@ -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"
}
}
}
}
+1 -2
View File
@@ -7,7 +7,6 @@
},
"dependencies": {
"axios": "^1.6.7",
"js-yaml": "^4.1.0",
"openai": "^4.28.0"
"js-yaml": "^4.1.0"
}
}
+5 -43
View File
@@ -16,17 +16,10 @@ import { step, line, ok, error, result } from './log.js';
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
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,30 +63,18 @@ 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}` };
}
}
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));
@@ -106,24 +87,6 @@ export async function verifyLLM() {
}
}
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 驗證失敗` };
}
/**
* 集中執行所有驗證相關設定的前置檢查;全部通過回傳 true,任一失敗回傳 false。
* 僅做唯讀的認證/連線確認,不發布任何 comment。
@@ -172,8 +135,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;
+59 -157
View File
@@ -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,121 +122,78 @@ 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 = 'https://opencode.local:4096';
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('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;
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
process.env.OPENCODE_PROVIDER = 'google';
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'));
if (url.endsWith('/global/health')) return { data: { healthy: true } };
return { data: { providers: [{ id: 'anthropic', 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, /無法連線/);
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 = [];
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);
});
});
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);
});