Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bfdefa4ba | |||
| 1492f1b9b4 | |||
| a9ac7857be | |||
| 3f78543159 | |||
| 98eaeb6050 | |||
| ffc9038923 | |||
| 862f4e46ef | |||
| 97888f8b22 | |||
| fa95a463f8 | |||
| 60001499da |
@@ -1,30 +1 @@
|
|||||||
[
|
[]
|
||||||
{
|
|
||||||
"level": "critical",
|
|
||||||
"role": "Mage",
|
|
||||||
"location": "app/roles.js:30",
|
|
||||||
"suggestion": "在 `parseRoleFile` 函式中,`yaml.load(match[1])` 若遇到格式錯誤的 YAML 內容,會拋出未捕捉的例外,導致應用程式崩潰。應加入 `try-catch` 區塊來處理此潛在錯誤,確保即使角色定義檔有誤,系統也能穩定運行,例如回傳一個錯誤物件或記錄錯誤並跳過該檔案。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "critical",
|
|
||||||
"role": "Rogue",
|
|
||||||
"location": "app/roles.js:30",
|
|
||||||
"suggestion": "「loadRole」函式每次被呼叫時,都會重新讀取並解析所有角色檔案。這會造成不必要的同步檔案 I/O 與 CPU 浪費,尤其當此函式被頻繁呼叫時,會嚴重阻塞事件迴圈。建議將「readRoleFiles()」的結果快取起來,讓「loadRole」直接從記憶體中查詢,避免重複讀取磁碟。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Mage",
|
|
||||||
"location": "app/roles.js:60",
|
|
||||||
"suggestion": "在 `buildAnalysisPrompt` 函式中,`role.focus` 屬性被直接用於字串模板。若角色定義檔中缺少 `focus` 欄位,此處將會顯示為 `負責「undefined」面向`,導致生成的提示語義不完整。建議在引用前檢查 `role.focus` 是否存在,或提供一個預設值,例如:`負責「${role.focus || '未定義'}」面向`。",
|
|
||||||
"is_new": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"level": "warning",
|
|
||||||
"role": "Mage",
|
|
||||||
"location": "app/roles.js:77",
|
|
||||||
"suggestion": "在 `getRoleIntro` 函式中,`r.focus` 和 `r.personality` 屬性被直接用於生成 Markdown 表格。若角色定義檔中缺少這些欄位,表格中將會顯示 `undefined`,影響可讀性與呈現品質。建議在引用前檢查這些屬性是否存在,並提供一個空字串或預設值,例如:`| **${badge}${r.name}** | ${r.focus || ''} | ${r.personality || ''} |`。",
|
|
||||||
"is_new": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
|
OpenAI GPT-5.5 會透過 Responses API 呼叫;設定方式仍使用 `OPENAI_*`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
OPENAI_BASE_URL: https://api.openai.com/v1
|
||||||
|
OPENAI_MODEL: gpt-5.5
|
||||||
|
```
|
||||||
|
|
||||||
### 2. OpenRouter
|
### 2. OpenRouter
|
||||||
```yaml
|
```yaml
|
||||||
name: AI
|
name: AI
|
||||||
@@ -192,7 +200,54 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Ollama
|
### 6. OpenCode Server
|
||||||
|
```yaml
|
||||||
|
name: AI
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.head_ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches-ignore:
|
||||||
|
- master
|
||||||
|
types: [opened, synchronize]
|
||||||
|
jobs:
|
||||||
|
code-review:
|
||||||
|
name: Code Review
|
||||||
|
runs-on: ubuntu
|
||||||
|
steps:
|
||||||
|
- name: AI Code Review
|
||||||
|
uses: https://gitea.jsc.idv.tw/actions/code-review@${{ vars.ACTION_CODE_REVIEW_VERSION }}
|
||||||
|
with:
|
||||||
|
GITEA_TOKEN: ${{ secrets.RUNNER_TOKEN }}
|
||||||
|
GITEA_COMMENT_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
OPENCODE_BASE_URL: http://192.168.3.124:4096
|
||||||
|
OPENCODE_PROVIDER: google
|
||||||
|
OPENCODE_MODEL: gemini-2.5-flash
|
||||||
|
# 若 OpenCode server 有設定 OPENCODE_SERVER_PASSWORD,才需要提供:
|
||||||
|
# OPENCODE_SERVER_USERNAME: opencode
|
||||||
|
# OPENCODE_SERVER_PASSWORD: ${{ secrets.OPENCODE_SERVER_PASSWORD }}
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenCode Server 串接方式會呼叫 server root 的 `/session` 與 `/session/{sessionID}/message`,並把模型指定為 `providerID=google`、`modelID=gemini-2.5-flash`。可用的內部 OpenCode server:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OPENCODE_BASE_URL: https://opencode.jsc.idv.me
|
||||||
|
```
|
||||||
|
|
||||||
|
或:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
OPENCODE_BASE_URL: http://192.168.3.124:4096
|
||||||
|
```
|
||||||
|
|
||||||
|
OpenCode server 本身必須已設定好 `google` provider 與 `gemini-2.5-flash` model;此 action 不會把 Google API key 傳給 OpenCode server。
|
||||||
|
|
||||||
|
### 7. Ollama
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: AI
|
name: AI
|
||||||
|
|||||||
+22
@@ -79,6 +79,23 @@ inputs:
|
|||||||
description: 'Amazon Q Base URL'
|
description: 'Amazon Q Base URL'
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
|
# OpenCode Server
|
||||||
|
OPENCODE_BASE_URL:
|
||||||
|
description: 'OpenCode server Base URL'
|
||||||
|
required: false
|
||||||
|
OPENCODE_MODEL:
|
||||||
|
description: 'OpenCode model id'
|
||||||
|
required: false
|
||||||
|
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
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: 'docker'
|
using: 'docker'
|
||||||
image: 'Dockerfile'
|
image: 'Dockerfile'
|
||||||
@@ -107,3 +124,8 @@ runs:
|
|||||||
OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }}
|
OLLAMA_MODEL: ${{ inputs.OLLAMA_MODEL }}
|
||||||
AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }}
|
AMAZONQ_API_KEY: ${{ inputs.AMAZONQ_API_KEY }}
|
||||||
AMAZONQ_BASE_URL: ${{ inputs.AMAZONQ_BASE_URL }}
|
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 }}
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ describe('postNewCriticalComments', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
|
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
|
||||||
|
const criticalCommentPattern = /嚴重問題/;
|
||||||
const inlineCalls = [];
|
const inlineCalls = [];
|
||||||
const issueCalls = [];
|
const issueCalls = [];
|
||||||
const findings = [
|
const findings = [
|
||||||
@@ -181,6 +182,6 @@ describe('postNewCriticalComments', () => {
|
|||||||
assert.equal(inlineCalls[0].path, 'app/a.js');
|
assert.equal(inlineCalls[0].path, 'app/a.js');
|
||||||
assert.equal(inlineCalls[0].line, 10);
|
assert.equal(inlineCalls[0].line, 10);
|
||||||
assert.equal(issueCalls.length, 2);
|
assert.equal(issueCalls.length, 2);
|
||||||
assert.ok(issueCalls.every(b => /嚴重問題/.test(b)));
|
assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function getLLMConfig() {
|
|||||||
['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'],
|
['gemini', splitKeys(process.env.GEMINI_API_KEY), process.env.GEMINI_BASE_URL || 'https://generativelanguage.googleapis.com/v1beta', process.env.GEMINI_MODEL || 'gemini-2.5-flash'],
|
||||||
['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL],
|
['ollama', ['ollama'], process.env.OLLAMA_BASE_URL, process.env.OLLAMA_MODEL],
|
||||||
['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'],
|
['amazonq', splitKeys(process.env.AMAZONQ_API_KEY), process.env.AMAZONQ_BASE_URL || 'https://q.api.aws', process.env.AMAZONQ_MODEL || 'amazon-q'],
|
||||||
|
['opencode', ['opencode'], process.env.OPENCODE_BASE_URL, process.env.OPENCODE_MODEL || 'gemini-2.5-flash'],
|
||||||
];
|
];
|
||||||
for (const [provider, apiKeys, baseURL, model] of checks) {
|
for (const [provider, apiKeys, baseURL, model] of checks) {
|
||||||
if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model };
|
if (apiKeys.length > 0 && baseURL) return { provider, apiKeys, baseURL, model };
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const ENV_KEYS = [
|
|||||||
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
||||||
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
|
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
|
||||||
];
|
];
|
||||||
|
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -84,6 +86,24 @@ describe('getLLMConfig', () => {
|
|||||||
assert.equal(cfg.model, 'my-amazon-model');
|
assert.equal(cfg.model, 'my-amazon-model');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('detects opencode server with gemini defaults', () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
assert.equal(cfg.provider, 'opencode');
|
||||||
|
assert.deepEqual(cfg.apiKeys, ['opencode']);
|
||||||
|
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
|
||||||
|
assert.equal(cfg.model, 'gemini-2.5-flash');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects opencode server with custom model', () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
||||||
|
const cfg = getLLMConfig();
|
||||||
|
assert.equal(cfg.provider, 'opencode');
|
||||||
|
assert.equal(cfg.baseURL, 'http://opencode.local:4096');
|
||||||
|
assert.equal(cfg.model, 'google/gemini-2.5-pro');
|
||||||
|
});
|
||||||
|
|
||||||
it('openai takes priority over gemini when both set', () => {
|
it('openai takes priority over gemini when both set', () => {
|
||||||
process.env.OPENAI_API_KEY = 'sk-test';
|
process.env.OPENAI_API_KEY = 'sk-test';
|
||||||
process.env.GEMINI_API_KEY = 'gemini-key';
|
process.env.GEMINI_API_KEY = 'gemini-key';
|
||||||
|
|||||||
+83
-4
@@ -2,6 +2,81 @@ import axios from 'axios';
|
|||||||
import { getLLMConfig } from './config.js';
|
import { getLLMConfig } from './config.js';
|
||||||
import { line, error } from './log.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 extractOpenCodeContent(data) {
|
||||||
|
const parts = data.parts || data.data?.parts || data.info?.content || data.data?.info?.content || [];
|
||||||
|
return parts
|
||||||
|
.map(part => part.text || part.content || '')
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chatOpenCode(baseURL, model, systemPrompt, userContent, headers) {
|
||||||
|
const base = baseURL.replace(/\/$/, '');
|
||||||
|
const { providerID, modelID } = opencodeModelConfig(model);
|
||||||
|
const session = await axios.post(
|
||||||
|
`${base}/session`,
|
||||||
|
{ title: 'AI Code Review', model: { providerID, id: modelID } },
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
const sessionID = session.data.id || session.data.data?.id;
|
||||||
|
if (!sessionID) throw new Error('OpenCode session 建立失敗:回應中沒有 session id');
|
||||||
|
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${base}/session/${sessionID}/message`,
|
||||||
|
{
|
||||||
|
model: { providerID, modelID },
|
||||||
|
system: systemPrompt,
|
||||||
|
parts: [{ type: 'text', text: userContent }],
|
||||||
|
},
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
return extractOpenCodeContent(resp.data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function chat(systemPrompt, userContent) {
|
export async function chat(systemPrompt, userContent) {
|
||||||
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
const { provider, apiKeys, baseURL, model } = getLLMConfig();
|
||||||
if (!provider) throw new Error('未設定任何 LLM API Key');
|
if (!provider) throw new Error('未設定任何 LLM API Key');
|
||||||
@@ -13,14 +88,18 @@ export async function chat(systemPrompt, userContent) {
|
|||||||
|
|
||||||
const shuffled = [...apiKeys].sort(() => Math.random() - 0.5);
|
const shuffled = [...apiKeys].sort(() => Math.random() - 0.5);
|
||||||
for (let i = 0; i < shuffled.length; i++) {
|
for (let i = 0; i < shuffled.length; i++) {
|
||||||
if (provider !== 'ollama') headers['Authorization'] = `Bearer ${shuffled[i]}`;
|
if (provider !== 'ollama' && provider !== 'opencode') headers['Authorization'] = `Bearer ${shuffled[i]}`;
|
||||||
try {
|
try {
|
||||||
|
if (provider === 'opencode') {
|
||||||
|
applyOpenCodeAuth(headers);
|
||||||
|
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
|
||||||
|
}
|
||||||
const resp = await axios.post(
|
const resp = await axios.post(
|
||||||
`${baseURL.replace(/\/$/, '')}/chat/completions`,
|
chatEndpoint(baseURL, provider, model),
|
||||||
{ model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 },
|
chatPayload(provider, model, systemPrompt, userContent),
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
return resp.data.choices[0].message.content;
|
return extractContent(provider, model, resp.data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const ENV_KEYS = [
|
|||||||
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
|
'CLAUDE_API_KEY', 'CLAUDE_BASE_URL', 'CLAUDE_MODEL',
|
||||||
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
|
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
|
||||||
];
|
];
|
||||||
|
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -125,6 +127,68 @@ describe('chat - key rotation', async () => {
|
|||||||
await chat('sys', 'user');
|
await chat('sys', 'user');
|
||||||
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses OpenCode server session API for opencode', async () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_PROVIDER = 'google';
|
||||||
|
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
||||||
|
const calls = [];
|
||||||
|
mock.method(axios, 'post', async (url, payload, opts) => {
|
||||||
|
calls.push({ url, payload, headers: opts.headers });
|
||||||
|
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' });
|
||||||
|
assert.equal(calls[1].url, 'http://opencode.local:4096/session/ses_test/message');
|
||||||
|
assert.deepEqual(calls[1].payload.model, { providerID: 'google', modelID: 'gemini-2.5-flash' });
|
||||||
|
assert.equal(calls[1].payload.system, 'sys');
|
||||||
|
assert.deepEqual(calls[1].payload.parts, [{ type: 'text', text: 'user' }]);
|
||||||
|
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('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 () => {
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
let calls = 0;
|
||||||
|
mock.method(axios, 'post', async () => {
|
||||||
|
calls += 1;
|
||||||
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('chatJSON', async () => {
|
describe('chatJSON', async () => {
|
||||||
|
|||||||
+32
-3
@@ -15,6 +15,17 @@ import { step, line, ok, error } from './log.js';
|
|||||||
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
const httpsAgent = GITEA_SKIP_TLS_VERIFY ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
||||||
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
const api = (path) => `${GITEA_SERVER_URL.replace(/\/$/, '')}/api/v1${path}`;
|
||||||
const giteaHeaders = (token) => ({ Authorization: `token ${token}`, 'Content-Type': 'application/json' });
|
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')}`;
|
||||||
|
};
|
||||||
|
|
||||||
function giteaErr(e) {
|
function giteaErr(e) {
|
||||||
const status = e.response?.status;
|
const status = e.response?.status;
|
||||||
@@ -63,6 +74,7 @@ export async function verifyLLM() {
|
|||||||
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
|
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
|
||||||
|
|
||||||
const base = baseURL.replace(/\/$/, '');
|
const base = baseURL.replace(/\/$/, '');
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
if (provider === 'ollama') {
|
if (provider === 'ollama') {
|
||||||
try {
|
try {
|
||||||
@@ -73,14 +85,31 @@ export async function verifyLLM() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = { 'Content-Type': 'application/json' };
|
if (provider === 'opencode') {
|
||||||
|
const { providerID, modelID } = opencodeModelConfig(model);
|
||||||
|
applyOpenCodeAuth(headers);
|
||||||
|
try {
|
||||||
|
await axios.get(`${base}/global/health`, { headers, timeout: 30000 });
|
||||||
|
const providers = await axios.get(`${base}/config/providers`, { headers, timeout: 30000 });
|
||||||
|
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';
|
if (provider === 'claude') headers['anthropic-version'] = '2023-06-01';
|
||||||
const payload = { model, messages: [{ role: 'user', content: 'ping' }], max_tokens: 1, temperature: 0 };
|
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++) {
|
for (let i = 0; i < apiKeys.length; i++) {
|
||||||
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
|
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
|
||||||
try {
|
try {
|
||||||
await axios.post(`${base}/chat/completions`, payload, { headers, timeout: 30000 });
|
await axios.post(endpoint, payload, { headers, timeout: 30000 });
|
||||||
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
|
return { ok: true, provider, keyIndex: i + 1, total: apiKeys.length };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
|
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const LLM_ENV_KEYS = [
|
|||||||
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
'GEMINI_API_KEY', 'GEMINI_BASE_URL', 'GEMINI_MODEL',
|
||||||
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
'OLLAMA_BASE_URL', 'OLLAMA_MODEL',
|
||||||
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
'AMAZONQ_API_KEY', 'AMAZONQ_BASE_URL', 'AMAZONQ_MODEL',
|
||||||
|
'OPENCODE_BASE_URL', 'OPENCODE_MODEL', 'OPENCODE_PROVIDER',
|
||||||
|
'OPENCODE_SERVER_USERNAME', 'OPENCODE_SERVER_PASSWORD',
|
||||||
];
|
];
|
||||||
|
|
||||||
function clearLLMEnv() {
|
function clearLLMEnv() {
|
||||||
@@ -163,6 +165,41 @@ describe('verifyLLM', () => {
|
|||||||
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
assert.equal(capturedHeaders['anthropic-version'], '2023-06-01');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('checks opencode server provider and model', async () => {
|
||||||
|
clearLLMEnv();
|
||||||
|
process.env.OPENCODE_BASE_URL = 'http://opencode.local:4096';
|
||||||
|
process.env.OPENCODE_PROVIDER = 'google';
|
||||||
|
process.env.OPENCODE_MODEL = 'gemini-2.5-flash';
|
||||||
|
const urls = [];
|
||||||
|
mock.method(axios, 'get', async (url) => {
|
||||||
|
urls.push(url);
|
||||||
|
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('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 () => {
|
it('checks base URL connectivity for ollama (no key)', async () => {
|
||||||
clearLLMEnv();
|
clearLLMEnv();
|
||||||
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
|
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';
|
||||||
|
|||||||
+20
-6
@@ -2,6 +2,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import yaml from 'js-yaml';
|
import yaml from 'js-yaml';
|
||||||
|
import { warn } from './log.js';
|
||||||
|
|
||||||
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
|
const ROLES_DIR = path.join(fileURLToPath(import.meta.url), '..', 'prompts', 'roles');
|
||||||
|
|
||||||
@@ -17,11 +18,24 @@ export function parseRoleFile(content) {
|
|||||||
return { ...meta, body: match[2].trim() };
|
return { ...meta, body: match[2].trim() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let cachedRoles = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讀取並解析所有角色 .md,結果快取於模組層級(單次程序生命週期內檔案不變)。
|
||||||
|
* 單一檔案解析失敗(壞 YAML、缺 frontmatter 等)時記錄警告並略過,不讓整個流程崩潰。
|
||||||
|
*/
|
||||||
function readRoleFiles() {
|
function readRoleFiles() {
|
||||||
return fs.readdirSync(ROLES_DIR)
|
if (cachedRoles) return cachedRoles;
|
||||||
.filter(f => f.endsWith('.md'))
|
const roles = [];
|
||||||
.sort()
|
for (const f of fs.readdirSync(ROLES_DIR).filter(f => f.endsWith('.md')).sort()) {
|
||||||
.map(f => parseRoleFile(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8')));
|
try {
|
||||||
|
roles.push(parseRoleFile(fs.readFileSync(path.join(ROLES_DIR, f), 'utf8')));
|
||||||
|
} catch (e) {
|
||||||
|
warn(`角色檔解析失敗,已略過: ${f}(${e.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cachedRoles = roles;
|
||||||
|
return cachedRoles;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,7 +58,7 @@ export function loadRole(name) {
|
|||||||
*/
|
*/
|
||||||
export function buildAnalysisPrompt(role) {
|
export function buildAnalysisPrompt(role) {
|
||||||
return [
|
return [
|
||||||
`你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus}」面向的程式碼審查(攻擊方)。`,
|
`你是 ${role.badge ? role.badge + ' ' : ''}${role.name},負責「${role.focus || '綜合'}」面向的程式碼審查(攻擊方)。`,
|
||||||
role.personality ? `個性:${role.personality}` : '',
|
role.personality ? `個性:${role.personality}` : '',
|
||||||
'',
|
'',
|
||||||
role.body,
|
role.body,
|
||||||
@@ -77,7 +91,7 @@ export function getRoleIntro(roles) {
|
|||||||
];
|
];
|
||||||
for (const r of roles) {
|
for (const r of roles) {
|
||||||
const badge = r.badge ? `${r.badge} ` : '';
|
const badge = r.badge ? `${r.badge} ` : '';
|
||||||
lines.push(`| **${badge}${r.name}** | ${r.focus} | ${r.personality} |`);
|
lines.push(`| **${badge}${r.name}** | ${r.focus || ''} | ${r.personality || ''} |`);
|
||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,11 @@ describe('buildAnalysisPrompt', () => {
|
|||||||
assert.match(prompt, /審查重點:邊界與空值/);
|
assert.match(prompt, /審查重點:邊界與空值/);
|
||||||
assert.match(prompt, /只回傳 JSON 陣列/);
|
assert.match(prompt, /只回傳 JSON 陣列/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('falls back to a default when focus is missing instead of showing undefined', () => {
|
||||||
|
const prompt = buildAnalysisPrompt({ name: 'NoFocus', body: 'x' });
|
||||||
|
assert.doesNotMatch(prompt, /undefined/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getRoleIntro', () => {
|
describe('getRoleIntro', () => {
|
||||||
@@ -80,4 +85,10 @@ describe('getRoleIntro', () => {
|
|||||||
assert.match(intro, /🔮 Tester/);
|
assert.match(intro, /🔮 Tester/);
|
||||||
assert.match(intro, /logic/);
|
assert.match(intro, /logic/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders empty cells instead of undefined when focus/personality are missing', () => {
|
||||||
|
const intro = getRoleIntro([{ name: 'Bare' }]);
|
||||||
|
assert.match(intro, /Bare/);
|
||||||
|
assert.doesNotMatch(intro, /undefined/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user