refactor: switch ai-code-review to CLIProxyAPI
CI / 1. BUILD (pull_request) Successful in 3s
CI / 2. TEST (pull_request) Skipped
CI / 3. RESULT (pull_request) Skipped

This commit is contained in:
2026-08-06 17:42:49 +00:00
parent dc82b3c6d3
commit 21687fd2bd
11 changed files with 341 additions and 543 deletions
+63 -96
View File
@@ -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('抽不到錯誤行時退取尾段(不取開頭)', () => {