Compare commits

...

7 Commits

12 changed files with 406 additions and 19 deletions
+1 -9
View File
@@ -1,9 +1 @@
[
{
"level": "warning",
"role": "Bard",
"location": "app/comments.test.js:172",
"suggestion": "此處斷言使用了魔術字串 `/嚴重問題/`,就像樂譜中突然出現的無標記音符,雖能理解,卻少了點優雅與明確。建議將此字串提取為一個具名常數,或至少賦予一個描述性變數,以提升可讀性與未來維護的便利性,讓意圖更加清晰。",
"is_new": true
}
]
[]
+56 -1
View File
@@ -74,6 +74,14 @@ jobs:
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
```yaml
name: AI
@@ -192,7 +200,54 @@ jobs:
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
name: AI
+22
View File
@@ -79,6 +79,23 @@ inputs:
description: 'Amazon Q Base URL'
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:
using: 'docker'
image: 'Dockerfile'
@@ -107,3 +124,8 @@ runs:
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 }}
+2 -1
View File
@@ -163,6 +163,7 @@ describe('postNewCriticalComments', () => {
});
it('handles multiple criticals, posting inline where possible and degrading the rest', async () => {
const criticalCommentPattern = /嚴重問題/;
const inlineCalls = [];
const issueCalls = [];
const findings = [
@@ -181,6 +182,6 @@ describe('postNewCriticalComments', () => {
assert.equal(inlineCalls[0].path, 'app/a.js');
assert.equal(inlineCalls[0].line, 10);
assert.equal(issueCalls.length, 2);
assert.ok(issueCalls.every(b => /嚴重問題/.test(b)));
assert.ok(issueCalls.every(b => criticalCommentPattern.test(b)));
});
});
+1
View File
@@ -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'],
['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 };
+20
View File
@@ -8,6 +8,8 @@ const ENV_KEYS = [
'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',
];
let saved = {};
@@ -84,6 +86,24 @@ describe('getLLMConfig', () => {
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', () => {
process.env.OPENAI_API_KEY = 'sk-test';
process.env.GEMINI_API_KEY = 'gemini-key';
+4
View File
@@ -203,6 +203,10 @@ export async function mergeInstructionText(existingText, sourceText, relPath, ai
try {
const aiMerged = await aiMergeAssistant({ relPath, existingText, sourceText, deterministicText: deterministic, requiredBlocks });
if (aiMerged == null) {
warn(`[merge] ${relPath} AI result unavailable; using deterministic merge`);
return deterministic;
}
if (typeof aiMerged === 'string' && validateMergedInstructionText(aiMerged, requiredBlocks)) {
return normalizeText(aiMerged) === normalizeText(existingText) ? existingText : aiMerged;
}
+9
View File
@@ -211,6 +211,15 @@ describe('commitAndPush', () => {
assert.ok(result.includes('extra block'));
});
it('uses deterministic instruction merge when AI returns no usable result', async () => {
const aiMergeAssistant = async () => null;
const result = await mergeInstructionText('repo block', 'source block', 'AGENTS.md', aiMergeAssistant);
assert.ok(result.includes('repo block'));
assert.ok(result.includes('source block'));
});
it('exits when AI output drops a block', async () => {
const originalExit = process.exit;
let exitCode = null;
+144 -5
View File
@@ -2,6 +2,81 @@ import axios from 'axios';
import { getLLMConfig } from './config.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) {
const { provider, apiKeys, baseURL, model } = getLLMConfig();
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);
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 {
if (provider === 'opencode') {
applyOpenCodeAuth(headers);
return await chatOpenCode(baseURL, model, systemPrompt, userContent, headers);
}
const resp = await axios.post(
`${baseURL.replace(/\/$/, '')}/chat/completions`,
{ model, messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userContent }], temperature: 0.2 },
chatEndpoint(baseURL, provider, model),
chatPayload(provider, model, systemPrompt, userContent),
{ headers }
);
return resp.data.choices[0].message.content;
return extractContent(provider, model, resp.data);
} catch (e) {
line(`[LLM] key[${i + 1}/${shuffled.length}] 失敗: ${e.message}`);
}
@@ -32,9 +111,69 @@ export async function chat(systemPrompt, userContent) {
export async function chatJSON(systemPrompt, userContent) {
const text = await chat(systemPrompt, userContent);
try {
return JSON.parse(text.trim().replace(/^```[^\n]*\n?/, '').replace(/```$/, '').trim());
return JSON.parse(extractJSONText(text));
} catch (e) {
line(`[LLM] JSON 解析失敗: ${e.message}`);
return [];
}
}
function stripOuterFence(text) {
return String(text)
.trim()
.replace(/^```[a-zA-Z0-9_-]*\n?/, '')
.replace(/```$/, '')
.trim();
}
function extractBalancedJSON(text, startIndex) {
const source = String(text);
const open = source[startIndex];
const close = open === '{' ? '}' : ']';
let depth = 0;
let inString = false;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const ch = source[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === open) depth += 1;
else if (ch === close) {
depth -= 1;
if (depth === 0) return source.slice(startIndex, i + 1);
}
}
return null;
}
function extractJSONText(text) {
const stripped = stripOuterFence(text);
try {
JSON.parse(stripped);
return stripped;
} catch {}
for (let i = 0; i < stripped.length; i++) {
if (stripped[i] !== '[' && stripped[i] !== '{') continue;
const candidate = extractBalancedJSON(stripped, i);
if (!candidate) continue;
try {
JSON.parse(candidate);
return candidate;
} catch {}
}
return stripped;
}
+78
View File
@@ -10,6 +10,8 @@ const ENV_KEYS = [
'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',
];
let saved = {};
@@ -125,6 +127,68 @@ describe('chat - key rotation', async () => {
await chat('sys', 'user');
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 () => {
@@ -144,6 +208,20 @@ describe('chatJSON', async () => {
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.')]);
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"}')]);
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')]);
+32 -3
View File
@@ -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 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')}`;
};
function giteaErr(e) {
const status = e.response?.status;
@@ -63,6 +74,7 @@ export async function verifyLLM() {
if (!baseURL) return { ok: false, provider, error: `${provider} 缺少 base URL` };
const base = baseURL.replace(/\/$/, '');
const headers = { 'Content-Type': 'application/json' };
if (provider === 'ollama') {
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';
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++) {
headers['Authorization'] = `Bearer ${apiKeys[i]}`;
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 };
} catch (e) {
line(`[preflight] LLM key[${i + 1}/${apiKeys.length}] 驗證失敗: ${e.message}`);
+37
View File
@@ -9,6 +9,8 @@ const LLM_ENV_KEYS = [
'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',
];
function clearLLMEnv() {
@@ -163,6 +165,41 @@ describe('verifyLLM', () => {
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 () => {
clearLLMEnv();
process.env.OLLAMA_BASE_URL = 'http://ollama.local/v1';