refactor: switch ai-code-review to CLIProxyAPI
This commit is contained in:
+30
-34
@@ -1,9 +1,10 @@
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { getLLMCLICommands, getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
||||
import { getLLMConfig, getOpenCodeHttpsAgent } from '../config.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL',
|
||||
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
|
||||
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
|
||||
];
|
||||
|
||||
let saved = {};
|
||||
@@ -19,48 +20,43 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('getLLMConfig', () => {
|
||||
it('exports the supported assistant CLI commands', () => {
|
||||
assert.deepEqual(getLLMCLICommands(), ['codex', 'claude', 'agy', 'antigravity', 'opencode']);
|
||||
});
|
||||
|
||||
it('returns null provider when no env vars set', () => {
|
||||
const cfg = getLLMConfig({ commandExistsFn: () => false });
|
||||
const cfg = getLLMConfig();
|
||||
assert.equal(cfg.provider, null);
|
||||
assert.deepEqual(cfg.apiKeys, []);
|
||||
assert.equal(cfg.baseURL, null);
|
||||
assert.equal(cfg.model, null);
|
||||
});
|
||||
|
||||
it('reads CLIProxyAPI settings from env', () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.CLI_PROXY_API_KEY = 'secret';
|
||||
process.env.MODEL = 'gpt-5.5';
|
||||
|
||||
const cfg = getLLMConfig();
|
||||
|
||||
assert.equal(cfg.provider, 'cliproxyapi');
|
||||
assert.deepEqual(cfg.apiKeys, ['secret']);
|
||||
assert.equal(cfg.baseURL, 'https://proxy.example');
|
||||
assert.equal(cfg.model, 'gpt-5.5');
|
||||
assert.equal(cfg.command, null);
|
||||
});
|
||||
|
||||
it('detects the first installed assistant CLI with defaults', () => {
|
||||
const cfg = getLLMConfig({ commandExistsFn: command => command === 'claude' });
|
||||
assert.equal(cfg.provider, 'claude');
|
||||
assert.deepEqual(cfg.apiKeys, ['claude']);
|
||||
assert.equal(cfg.baseURL, null);
|
||||
assert.equal(cfg.command, 'claude');
|
||||
assert.equal(cfg.model, 'sonnet');
|
||||
});
|
||||
it('uses INPUT_MODEL over MODEL when both exist', () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.INPUT_MODEL = 'gpt-5-mini';
|
||||
process.env.MODEL = 'gpt-5.5';
|
||||
|
||||
const cfg = getLLMConfig();
|
||||
|
||||
it('uses MODEL for the selected assistant CLI', () => {
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' });
|
||||
assert.equal(cfg.provider, 'codex');
|
||||
assert.equal(cfg.command, 'codex');
|
||||
assert.equal(cfg.model, 'gpt-5-mini');
|
||||
});
|
||||
|
||||
it('detects Antigravity through the agy command', () => {
|
||||
const cfg = getLLMConfig({ commandExistsFn: command => command === 'agy' });
|
||||
assert.equal(cfg.provider, 'antigravity');
|
||||
assert.equal(cfg.command, 'agy');
|
||||
assert.equal(cfg.model, 'gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('can force a CLI with AI_ASSISTANT_CLI', () => {
|
||||
process.env.AI_ASSISTANT_CLI = 'opencode';
|
||||
process.env.OPENCODE_MODEL = 'google/gemini-2.5-pro';
|
||||
const cfg = getLLMConfig({ commandExistsFn: command => command === 'codex' || command === 'opencode' });
|
||||
assert.equal(cfg.provider, 'opencode');
|
||||
assert.equal(cfg.command, 'opencode');
|
||||
assert.equal(cfg.model, 'google/gemini-2.5-pro');
|
||||
it('returns null provider when CLI_PROXY_API is missing', () => {
|
||||
process.env.MODEL = 'gpt-5.5';
|
||||
const cfg = getLLMConfig();
|
||||
assert.equal(cfg.provider, null);
|
||||
assert.equal(cfg.baseURL, null);
|
||||
});
|
||||
|
||||
it('uses an insecure HTTPS agent for OpenCode', () => {
|
||||
|
||||
+63
-96
@@ -1,104 +1,73 @@
|
||||
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, writeFile, chmod, rm, readFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import axios from 'axios';
|
||||
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
|
||||
|
||||
const ENV_KEYS = [
|
||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH', 'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
||||
'FAKE_AI_STDOUT', 'FAKE_AI_STDERR', 'FAKE_AI_EXIT', 'FAKE_AI_STDIN_PATH', 'FAKE_AI_ARGS_PATH',
|
||||
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
|
||||
'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
||||
];
|
||||
|
||||
let saved = {};
|
||||
let tempDir;
|
||||
beforeEach(() => {
|
||||
saved = {};
|
||||
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
||||
tempDir = null;
|
||||
});
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
for (const k of ENV_KEYS) {
|
||||
if (saved[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = saved[k];
|
||||
}
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
async function installFakeCLI(command = 'codex') {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'ai-cli-test-'));
|
||||
const script = join(tempDir, command);
|
||||
await writeFile(script, `#!/bin/sh
|
||||
if [ -n "$FAKE_AI_ARGS_PATH" ]; then printf '%s\\n' "$*" > "$FAKE_AI_ARGS_PATH"; fi
|
||||
if [ -n "$FAKE_AI_STDIN_PATH" ]; then /bin/cat > "$FAKE_AI_STDIN_PATH"; else /bin/cat >/dev/null; fi
|
||||
if [ -n "$FAKE_AI_STDERR" ]; then printf '%s' "$FAKE_AI_STDERR" >&2; fi
|
||||
if [ -n "$FAKE_AI_STDOUT" ]; then printf '%s' "$FAKE_AI_STDOUT"; fi
|
||||
exit "\${FAKE_AI_EXIT:-0}"
|
||||
`);
|
||||
await chmod(script, 0o755);
|
||||
process.env.PATH = tempDir;
|
||||
return { stdinPath: join(tempDir, 'stdin.txt'), argsPath: join(tempDir, 'args.txt') };
|
||||
}
|
||||
|
||||
describe('chat - assistant CLI', async () => {
|
||||
describe('chat - CLIProxyAPI', async () => {
|
||||
const { chat } = await import('../llm.js');
|
||||
|
||||
it('runs the detected CLI with MODEL and sends the prompts through stdin', async () => {
|
||||
const { stdinPath, argsPath } = await installFakeCLI('codex');
|
||||
it('posts the prompts to /v1/chat/completions and returns the response text', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.CLI_PROXY_API_KEY = 'secret';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
process.env.FAKE_AI_STDOUT = 'cli response';
|
||||
process.env.FAKE_AI_STDIN_PATH = stdinPath;
|
||||
process.env.FAKE_AI_ARGS_PATH = argsPath;
|
||||
|
||||
let capturedUrl, capturedBody, capturedOpts;
|
||||
mock.method(axios, 'post', async (url, body, opts) => {
|
||||
capturedUrl = url;
|
||||
capturedBody = body;
|
||||
capturedOpts = opts;
|
||||
return {
|
||||
data: {
|
||||
choices: [{ message: { content: 'cli response' } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
headers: { 'x-ratelimit-remaining-tokens': '80', 'x-ratelimit-limit-tokens': '100' },
|
||||
};
|
||||
});
|
||||
|
||||
const result = await chat('sys', 'user');
|
||||
|
||||
assert.equal(result, 'cli response');
|
||||
assert.match(await readFile(argsPath, 'utf8'), /exec --model gpt-5-mini/);
|
||||
const prompt = await readFile(stdinPath, 'utf8');
|
||||
assert.match(prompt, /<system>\nsys\n<\/system>/);
|
||||
assert.match(prompt, /<user>\nuser\n<\/user>/);
|
||||
assert.equal(capturedUrl, 'https://proxy.example/v1/chat/completions');
|
||||
assert.equal(capturedBody.model, 'gpt-5-mini');
|
||||
assert.deepEqual(capturedBody.messages, [
|
||||
{ role: 'system', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。' },
|
||||
{ role: 'user', content: '請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。\n\n<system>\nsys\n</system>\n\n<user>\nuser\n</user>' },
|
||||
]);
|
||||
assert.equal(capturedBody.temperature, 0);
|
||||
assert.equal(capturedBody.stream, false);
|
||||
assert.equal(capturedOpts.headers.Authorization, 'Bearer secret');
|
||||
});
|
||||
|
||||
it('can force opencode with AI_ASSISTANT_CLI', async () => {
|
||||
const { argsPath } = await installFakeCLI('opencode');
|
||||
process.env.AI_ASSISTANT_CLI = 'opencode';
|
||||
process.env.MODEL = 'google/gemini-2.5-pro';
|
||||
process.env.FAKE_AI_STDOUT = 'ok';
|
||||
process.env.FAKE_AI_ARGS_PATH = argsPath;
|
||||
it('throws an error when the API fails', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
mock.method(axios, 'post', async () => {
|
||||
const e = new Error('Request failed');
|
||||
e.response = { status: 401, data: { error: { message: 'access token revoked' } } };
|
||||
throw e;
|
||||
});
|
||||
|
||||
const result = await chat('sys', 'user');
|
||||
|
||||
assert.equal(result, 'ok');
|
||||
assert.match(await readFile(argsPath, 'utf8'), /run --model google\/gemini-2.5-pro/);
|
||||
});
|
||||
|
||||
it('runs Antigravity through agy with MODEL and prompt argument', async () => {
|
||||
const { stdinPath, argsPath } = await installFakeCLI('agy');
|
||||
process.env.AI_ASSISTANT_CLI = 'agy';
|
||||
process.env.MODEL = 'gemini-2.5-pro';
|
||||
process.env.FAKE_AI_STDOUT = 'antigravity response';
|
||||
process.env.FAKE_AI_STDIN_PATH = stdinPath;
|
||||
process.env.FAKE_AI_ARGS_PATH = argsPath;
|
||||
|
||||
const result = await chat('sys', 'user');
|
||||
|
||||
assert.equal(result, 'antigravity response');
|
||||
const args = await readFile(argsPath, 'utf8');
|
||||
assert.match(args, /-p .*--model gemini-2.5-pro/s);
|
||||
assert.match(args, /<system>\nsys\n<\/system>/);
|
||||
assert.equal(await readFile(stdinPath, 'utf8'), '');
|
||||
});
|
||||
|
||||
it('throws an error when the CLI fails instead of exiting the process', async () => {
|
||||
await installFakeCLI('codex');
|
||||
process.env.FAKE_AI_EXIT = '2';
|
||||
process.env.FAKE_AI_STDERR = 'provider overloaded';
|
||||
const exitMock = mock.method(process, 'exit', () => { throw new Error('exit should not be called'); });
|
||||
|
||||
await assert.rejects(() => chat('sys', 'user'), /provider overloaded/);
|
||||
|
||||
assert.equal(exitMock.mock.calls.length, 0);
|
||||
await assert.rejects(() => chat('sys', 'user'), /401/);
|
||||
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,8 +75,9 @@ describe('chatJSON', async () => {
|
||||
const { chatJSON } = await import('../llm.js');
|
||||
|
||||
it('parses plain JSON response', async () => {
|
||||
await installFakeCLI('codex');
|
||||
process.env.FAKE_AI_STDOUT = '[{"level":"critical"}]';
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '[{"level":"critical"}]' } }] }, headers: {} }));
|
||||
|
||||
const result = await chatJSON('sys', 'user');
|
||||
|
||||
@@ -115,8 +85,9 @@ describe('chatJSON', async () => {
|
||||
});
|
||||
|
||||
it('strips markdown code block before parsing', async () => {
|
||||
await installFakeCLI('codex');
|
||||
process.env.FAKE_AI_STDOUT = '```json\n[{"level":"info"}]\n```';
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '```json\n[{"level":"info"}]\n```' } }] }, headers: {} }));
|
||||
|
||||
const result = await chatJSON('sys', 'user');
|
||||
|
||||
@@ -124,8 +95,9 @@ describe('chatJSON', async () => {
|
||||
});
|
||||
|
||||
it('extracts JSON array from surrounding prose', async () => {
|
||||
await installFakeCLI('codex');
|
||||
process.env.FAKE_AI_STDOUT = '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.';
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '**Reviewing findings**\n\n[{"level":"warning","suggestion":"x"}]\n\nDone.' } }] }, headers: {} }));
|
||||
|
||||
const result = await chatJSON('sys', 'user');
|
||||
|
||||
@@ -133,8 +105,9 @@ describe('chatJSON', async () => {
|
||||
});
|
||||
|
||||
it('extracts JSON object from surrounding prose', async () => {
|
||||
await installFakeCLI('codex');
|
||||
process.env.FAKE_AI_STDOUT = '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}';
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: '**Begin Combine**\n{"merged_text":"repo block\\n\\nsource block"}' } }] }, headers: {} }));
|
||||
|
||||
const result = await chatJSON('sys', 'user');
|
||||
|
||||
@@ -142,8 +115,9 @@ describe('chatJSON', async () => {
|
||||
});
|
||||
|
||||
it('returns [] when JSON is invalid', async () => {
|
||||
await installFakeCLI('codex');
|
||||
process.env.FAKE_AI_STDOUT = 'not json';
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5-mini';
|
||||
mock.method(axios, 'post', async () => ({ data: { choices: [{ message: { content: 'not json' } }] }, headers: {} }));
|
||||
|
||||
const result = await chatJSON('sys', 'user');
|
||||
|
||||
@@ -239,26 +213,19 @@ describe('extractJSONText', () => {
|
||||
});
|
||||
|
||||
describe('extractMeaningfulError', () => {
|
||||
it('抽出尾端真正的錯誤,而非開頭的 codex banner/回顯 prompt', () => {
|
||||
it('抽出尾端真正的錯誤,而非前段雜訊', () => {
|
||||
const raw = [
|
||||
'OpenAI Codex v0.142.5',
|
||||
'--------',
|
||||
'workdir: /workspace/actions/ai-code-review',
|
||||
'model: gpt-5.4-mini',
|
||||
'reasoning effort: none',
|
||||
'--------',
|
||||
'user',
|
||||
'請依照以下系統指示處理使用者內容,並只輸出要求的最終結果。',
|
||||
'ERROR codex_api::endpoint::responses_websocket: failed to connect to websocket: HTTP error: 401 Unauthorized',
|
||||
'ERROR: Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again.',
|
||||
'HTTP/1.1 401 Unauthorized',
|
||||
'{"error":{"message":"access token revoked"}}',
|
||||
'trace: proxy request failed',
|
||||
'ERROR: access token revoked',
|
||||
].join('\n');
|
||||
|
||||
const result = extractMeaningfulError(raw);
|
||||
|
||||
assert.match(result, /401 Unauthorized/);
|
||||
assert.match(result, /refresh token was revoked/);
|
||||
assert.doesNotMatch(result, /workdir:/);
|
||||
assert.doesNotMatch(result, /請依照以下系統指示/);
|
||||
assert.match(result, /access token revoked/);
|
||||
assert.doesNotMatch(result, /trace:/);
|
||||
});
|
||||
|
||||
it('抽不到錯誤行時退取尾段(不取開頭)', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ function baseStubs() {
|
||||
config: {
|
||||
GITEA_REPOSITORY: 'owner/repo', PR_NUMBER: '1', PR_HEAD_BRANCH: 'feat', PR_BASE_BRANCH: 'develop',
|
||||
FINDINGS_PATH: '.gitea/ai-review/findings.json', EXCLUSIONS_PATH: '.gitea/ai-review/exclusions.json',
|
||||
getLLMConfig: () => ({ provider: 'codex', apiKeys: ['codex'], baseURL: null, model: 'gpt-5.5', command: 'codex' }),
|
||||
getLLMConfig: () => ({ provider: 'cliproxyapi', apiKeys: ['secret'], baseURL: 'https://proxy.example', model: 'gpt-5.5', command: null }),
|
||||
},
|
||||
roles: { loadRoles: () => [{ name: 'Mage' }], getRoleIntro: () => 'intro' },
|
||||
gitea: {
|
||||
|
||||
+87
-130
@@ -1,52 +1,38 @@
|
||||
import { describe, it, afterEach, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import axios from 'axios';
|
||||
import { mkdtemp, writeFile, chmod, rm } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchCodexModels, runPreflight } from '../preflight.js';
|
||||
import { checkRequiredEnv, verifyGiteaToken, verifyCommentToken, verifyLLM, fetchLLMModels, runPreflight } from '../preflight.js';
|
||||
|
||||
const LLM_ENV_KEYS = [
|
||||
'AI_ASSISTANT_CLI', 'MODEL', 'OPENCODE_MODEL', 'PATH',
|
||||
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'INPUT_CLI_PROXY_API', 'INPUT_CLI_PROXY_API_KEY',
|
||||
'MODEL', 'OPENCODE_MODEL', 'INPUT_MODEL',
|
||||
];
|
||||
const ORIGINAL_PATH = process.env.PATH;
|
||||
|
||||
function clearLLMEnv() {
|
||||
for (const k of LLM_ENV_KEYS) delete process.env[k];
|
||||
}
|
||||
|
||||
let tempDir;
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
mock.restoreAll();
|
||||
clearLLMEnv();
|
||||
process.env.PATH = ORIGINAL_PATH;
|
||||
if (tempDir) await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
});
|
||||
|
||||
async function installFakeCLI(command = 'codex') {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'preflight-cli-test-'));
|
||||
const script = join(tempDir, command);
|
||||
await writeFile(script, '#!/bin/sh\nexit 0\n');
|
||||
await chmod(script, 0o755);
|
||||
process.env.PATH = tempDir;
|
||||
}
|
||||
|
||||
describe('checkRequiredEnv', () => {
|
||||
it('reports all three missing when nothing provided', () => {
|
||||
it('reports all missing values when nothing is provided', () => {
|
||||
const result = checkRequiredEnv({ token: '', repo: '', pr: '' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER']);
|
||||
assert.deepEqual(result.missing, ['GITEA_TOKEN', 'GITEA_REPOSITORY', 'PR_NUMBER', 'CLI_PROXY_API']);
|
||||
});
|
||||
|
||||
it('reports only the missing ones', () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
const result = checkRequiredEnv({ token: 't', repo: '', pr: '5' });
|
||||
assert.equal(result.ok, false);
|
||||
assert.deepEqual(result.missing, ['GITEA_REPOSITORY']);
|
||||
});
|
||||
|
||||
it('ok when all provided', () => {
|
||||
it('ok when all required values are provided', () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
const result = checkRequiredEnv({ token: 't', repo: 'owner/repo', pr: '5' });
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(result.missing, []);
|
||||
@@ -84,7 +70,7 @@ describe('verifyGiteaToken', () => {
|
||||
});
|
||||
|
||||
describe('verifyCommentToken', () => {
|
||||
it('skips when no comment token provided', async () => {
|
||||
it('skips when no comment token is provided', async () => {
|
||||
const result = await verifyCommentToken('');
|
||||
assert.deepEqual(result, { ok: true, skipped: true });
|
||||
});
|
||||
@@ -118,129 +104,93 @@ describe('verifyCommentToken', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyLLM', () => {
|
||||
it('fails when no supported assistant CLI is detected', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.AI_ASSISTANT_CLI = 'no-such-ai-cli';
|
||||
process.env.PATH = '';
|
||||
|
||||
const result = await verifyLLM();
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /AI 助理 CLI/);
|
||||
});
|
||||
|
||||
it('passes when a supported assistant CLI is detected and the model is in the codex list', async () => {
|
||||
clearLLMEnv();
|
||||
await installFakeCLI('codex');
|
||||
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||
process.env.MODEL = 'gpt-5.4-mini';
|
||||
|
||||
const result = await verifyLLM({
|
||||
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.provider, 'codex');
|
||||
assert.equal(result.command, 'codex');
|
||||
assert.equal(result.model, 'gpt-5.4-mini');
|
||||
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
|
||||
});
|
||||
|
||||
it('fails when codex auth is invalid (model list check reports 401)', async () => {
|
||||
clearLLMEnv();
|
||||
await installFakeCLI('codex');
|
||||
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||
process.env.MODEL = 'gpt-5.4-mini';
|
||||
|
||||
const result = await verifyLLM({
|
||||
fetchCodexModelsFn: async () => ({ ok: false, error: 'codex 認證失效(HTTP 401)——token 已被撤銷或過期,請重新登入 codex 並更新 LLM_OAUTH secret' }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.provider, 'codex');
|
||||
assert.match(result.error, /HTTP 401/);
|
||||
assert.match(result.error, /LLM_OAUTH/);
|
||||
});
|
||||
|
||||
it('fails when the configured model is not in the codex available list', async () => {
|
||||
clearLLMEnv();
|
||||
await installFakeCLI('codex');
|
||||
process.env.AI_ASSISTANT_CLI = 'codex';
|
||||
process.env.MODEL = 'gpt-9-imaginary';
|
||||
|
||||
const result = await verifyLLM({
|
||||
fetchCodexModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /不在 codex 可用清單/);
|
||||
assert.match(result.error, /gpt-5\.4-mini/);
|
||||
});
|
||||
|
||||
it('fails when a requested CLI is not installed', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.AI_ASSISTANT_CLI = 'missing-cli';
|
||||
process.env.PATH = '';
|
||||
|
||||
const result = await verifyLLM();
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /AI 助理 CLI/);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('fetchCodexModels', () => {
|
||||
async function writeAuth(json) {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'codex-auth-test-'));
|
||||
const authPath = join(tempDir, 'auth.json');
|
||||
await writeFile(authPath, JSON.stringify(json));
|
||||
return authPath;
|
||||
}
|
||||
|
||||
it('returns the model slugs on HTTP 200', async () => {
|
||||
const authPath = await writeAuth({ tokens: { access_token: 'tok', account_id: 'acc' } });
|
||||
describe('fetchLLMModels', () => {
|
||||
it('returns the model ids on HTTP 200', async () => {
|
||||
let capturedUrl, capturedHeaders;
|
||||
const result = await fetchCodexModels({
|
||||
authPath,
|
||||
const result = await fetchLLMModels({
|
||||
baseURL: 'https://proxy.example/',
|
||||
apiKey: 'tok',
|
||||
fetchImpl: async (url, opts) => {
|
||||
capturedUrl = url;
|
||||
capturedHeaders = opts.headers;
|
||||
return { status: 200, ok: true, json: async () => ({ models: [{ slug: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }] }) };
|
||||
return { status: 200, ok: true, json: async () => ({ data: [{ id: 'gpt-5.5' }, { slug: 'gpt-5.4-mini' }, 'custom-model'] }) };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] });
|
||||
assert.match(capturedUrl, /client_version=/);
|
||||
assert.equal(capturedHeaders['Authorization'], 'Bearer tok');
|
||||
assert.equal(capturedHeaders['chatgpt-account-id'], 'acc');
|
||||
assert.deepEqual(result, { ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini', 'custom-model'] });
|
||||
assert.equal(capturedUrl, 'https://proxy.example/v1/models');
|
||||
assert.equal(capturedHeaders.Authorization, 'Bearer tok');
|
||||
});
|
||||
|
||||
it('reports an auth failure on HTTP 401', async () => {
|
||||
const authPath = await writeAuth({ tokens: { access_token: 'revoked' } });
|
||||
const result = await fetchCodexModels({
|
||||
authPath,
|
||||
const result = await fetchLLMModels({
|
||||
baseURL: 'https://proxy.example',
|
||||
apiKey: 'revoked',
|
||||
fetchImpl: async () => ({ status: 401, ok: false, json: async () => ({}) }),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /HTTP 401/);
|
||||
assert.match(result.error, /LLM_OAUTH/);
|
||||
assert.match(result.error, /CLIProxyAPI/);
|
||||
});
|
||||
|
||||
it('fails when the auth file cannot be read', async () => {
|
||||
const result = await fetchCodexModels({
|
||||
authPath: join(tmpdir(), 'definitely-missing-codex-auth-xyz.json'),
|
||||
fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({ models: [] }) }),
|
||||
it('fails when the base URL is missing', async () => {
|
||||
const result = await fetchLLMModels({ baseURL: '', apiKey: 'tok', fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /CLI_PROXY_API/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyLLM', () => {
|
||||
it('fails when no CLIProxyAPI is configured', async () => {
|
||||
clearLLMEnv();
|
||||
const result = await verifyLLM();
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /CLIProxyAPI/);
|
||||
});
|
||||
|
||||
it('passes when the configured model is in the proxy model list', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.CLI_PROXY_API_KEY = 'secret';
|
||||
process.env.MODEL = 'gpt-5.4-mini';
|
||||
|
||||
const result = await verifyLLM({
|
||||
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||
});
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /無法讀取 codex 認證檔/);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.provider, 'cliproxyapi');
|
||||
assert.equal(result.command, null);
|
||||
assert.equal(result.model, 'gpt-5.4-mini');
|
||||
assert.deepEqual(result.models, ['gpt-5.5', 'gpt-5.4-mini']);
|
||||
});
|
||||
|
||||
it('fails when the auth file lacks an access_token', async () => {
|
||||
const authPath = await writeAuth({ tokens: {} });
|
||||
const result = await fetchCodexModels({ authPath, fetchImpl: async () => ({ status: 200, ok: true, json: async () => ({}) }) });
|
||||
it('fails when proxy auth is invalid', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-5.4-mini';
|
||||
|
||||
const result = await verifyLLM({
|
||||
fetchLLMModelsFn: async () => ({ ok: false, error: 'CLIProxyAPI 認證失效(HTTP 401)——API key 已被撤銷或過期,請更新 CLI_PROXY_API_KEY secret' }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /缺少 tokens\.access_token/);
|
||||
assert.equal(result.provider, 'cliproxyapi');
|
||||
assert.match(result.error, /HTTP 401/);
|
||||
assert.match(result.error, /CLI_PROXY_API_KEY/);
|
||||
});
|
||||
|
||||
it('fails when the configured model is not in the proxy available list', async () => {
|
||||
clearLLMEnv();
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
process.env.MODEL = 'gpt-9-imaginary';
|
||||
|
||||
const result = await verifyLLM({
|
||||
fetchLLMModelsFn: async () => ({ ok: true, slugs: ['gpt-5.5', 'gpt-5.4-mini'] }),
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.match(result.error, /不在 CLIProxyAPI 可用清單/);
|
||||
assert.match(result.error, /gpt-9-imaginary/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,7 +201,7 @@ describe('runPreflight', () => {
|
||||
verifyToken: async () => ({ ok: true }),
|
||||
verifyComment: async () => ({ ok: true }),
|
||||
verifyRemote: () => ({ ok: true }),
|
||||
verifyLLMFn: async () => ({ ok: true, provider: 'codex' }),
|
||||
verifyLLMFn: async () => ({ ok: true, provider: 'cliproxyapi', command: null, model: 'gpt-5.5', models: ['gpt-5.5'] }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -262,11 +212,13 @@ describe('runPreflight', () => {
|
||||
});
|
||||
|
||||
it('returns true when every verification step succeeds', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
const result = await runPreflight('/ws', makeDeps());
|
||||
assert.equal(result, true);
|
||||
});
|
||||
|
||||
it('returns true when the comment token check is skipped', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
const result = await runPreflight('/ws', makeDeps({
|
||||
verifyComment: async () => ({ ok: true, skipped: true }),
|
||||
}));
|
||||
@@ -274,6 +226,7 @@ describe('runPreflight', () => {
|
||||
});
|
||||
|
||||
it('returns false when the Gitea token check fails', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
let remoteCalled = false;
|
||||
const result = await runPreflight('/ws', makeDeps({
|
||||
verifyToken: async () => ({ ok: false, error: 'HTTP 401' }),
|
||||
@@ -284,6 +237,7 @@ describe('runPreflight', () => {
|
||||
});
|
||||
|
||||
it('returns false when the comment token check fails', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
const result = await runPreflight('/ws', makeDeps({
|
||||
verifyComment: async () => ({ ok: false, error: 'HTTP 401' }),
|
||||
}));
|
||||
@@ -291,6 +245,7 @@ describe('runPreflight', () => {
|
||||
});
|
||||
|
||||
it('returns false when git remote access fails', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
let llmCalled = false;
|
||||
const result = await runPreflight('/ws', makeDeps({
|
||||
verifyRemote: () => ({ ok: false, error: 'auth failed' }),
|
||||
@@ -301,13 +256,15 @@ describe('runPreflight', () => {
|
||||
});
|
||||
|
||||
it('returns false when LLM verification fails', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
const result = await runPreflight('/ws', makeDeps({
|
||||
verifyLLMFn: async () => ({ ok: false, error: 'AI 助理 CLI 驗證失敗' }),
|
||||
verifyLLMFn: async () => ({ ok: false, error: 'CLIProxyAPI 驗證失敗' }),
|
||||
}));
|
||||
assert.equal(result, false);
|
||||
});
|
||||
|
||||
it('passes the workspace through to the remote-access check', async () => {
|
||||
process.env.CLI_PROXY_API = 'https://proxy.example';
|
||||
let captured;
|
||||
await runPreflight('/custom/ws', makeDeps({
|
||||
verifyRemote: (ws) => { captured = ws; return { ok: true }; },
|
||||
|
||||
Reference in New Issue
Block a user