308 lines
10 KiB
JavaScript
308 lines
10 KiB
JavaScript
import { describe, it, beforeEach, afterEach, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import axios from 'axios';
|
|
import { extractBalancedJSON, extractJSONText, extractMeaningfulError, mapWithConcurrency } from '../llm.js';
|
|
|
|
const ENV_KEYS = [
|
|
'CLI_PROXY_API', 'CLI_PROXY_API_KEY', 'CLI_PROXY_API_MODEL', 'MODEL', 'INPUT_MODEL', 'OPENCODE_MODEL',
|
|
'AI_ASSISTANT_TIMEOUT_MS', 'AI_ASSISTANT_MAX_BUFFER',
|
|
];
|
|
|
|
let saved = {};
|
|
beforeEach(() => {
|
|
saved = {};
|
|
for (const k of ENV_KEYS) { saved[k] = process.env[k]; delete process.env[k]; }
|
|
});
|
|
afterEach(() => {
|
|
for (const k of ENV_KEYS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
mock.restoreAll();
|
|
});
|
|
|
|
describe('chat - CLIProxyAPI', async () => {
|
|
const { chat } = await import('../llm.js');
|
|
|
|
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';
|
|
|
|
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.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('omits model from the request body when auto selection is allowed', async () => {
|
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
|
process.env.CLI_PROXY_API_KEY = 'secret';
|
|
|
|
let capturedBody;
|
|
mock.method(axios, 'post', async (url, body) => {
|
|
capturedBody = body;
|
|
return {
|
|
data: { choices: [{ message: { content: 'cli response' } }] },
|
|
headers: {},
|
|
};
|
|
});
|
|
|
|
const result = await chat('sys', 'user');
|
|
|
|
assert.equal(result, 'cli response');
|
|
assert.equal(Object.hasOwn(capturedBody, 'model'), false);
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
await assert.rejects(() => chat('sys', 'user'), /401/);
|
|
await assert.rejects(() => chat('sys', 'user'), /access token revoked/);
|
|
});
|
|
|
|
it('throws when the configured model name is invalid', async () => {
|
|
process.env.CLI_PROXY_API = 'https://proxy.example';
|
|
process.env.MODEL = 'gpt-5.5; rm -rf /';
|
|
|
|
await assert.rejects(() => chat('sys', 'user'), /無效的 model 參數/);
|
|
});
|
|
});
|
|
|
|
describe('chatJSON', async () => {
|
|
const { chatJSON } = await import('../llm.js');
|
|
|
|
it('parses plain JSON response', async () => {
|
|
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');
|
|
|
|
assert.deepEqual(result, [{ level: 'critical' }]);
|
|
});
|
|
|
|
it('strips markdown code block before parsing', async () => {
|
|
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');
|
|
|
|
assert.deepEqual(result, [{ level: 'info' }]);
|
|
});
|
|
|
|
it('extracts JSON array from surrounding prose', async () => {
|
|
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');
|
|
|
|
assert.deepEqual(result, [{ level: 'warning', suggestion: 'x' }]);
|
|
});
|
|
|
|
it('extracts JSON object from surrounding prose', async () => {
|
|
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');
|
|
|
|
assert.deepEqual(result, { merged_text: 'repo block\n\nsource block' });
|
|
});
|
|
|
|
it('returns [] when JSON is invalid', async () => {
|
|
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');
|
|
|
|
assert.deepEqual(result, []);
|
|
});
|
|
});
|
|
|
|
describe('extractBalancedJSON', () => {
|
|
it('returns the whole object for a simple object from index 0', () => {
|
|
const text = '{"a":1}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
|
});
|
|
|
|
it('returns the full balanced segment for deeply nested object/array', () => {
|
|
const text = '{"a":[1,{"b":[2,{"c":3}]}],"d":4}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":[1,{"b":[2,{"c":3}]}],"d":4}');
|
|
});
|
|
|
|
it('does not let braces inside a string value break balancing', () => {
|
|
const text = '{"a":"}{"}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"}{"}');
|
|
});
|
|
|
|
it('handles an escaped quote inside a string value', () => {
|
|
const text = '{"a":"\\""}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":"\\""}');
|
|
});
|
|
|
|
it('returns null for truncated/incomplete JSON', () => {
|
|
const text = '{"a":1';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), null);
|
|
});
|
|
|
|
it('extracts a balanced array when starting at a "["', () => {
|
|
const text = '[1,[2,3],{"a":4}]';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '[1,[2,3],{"a":4}]');
|
|
});
|
|
|
|
it('excludes trailing content after the balanced segment', () => {
|
|
const text = '{"a":1} trailing text {"b":2}';
|
|
|
|
assert.equal(extractBalancedJSON(text, 0), '{"a":1}');
|
|
});
|
|
});
|
|
|
|
describe('extractJSONText', () => {
|
|
it('strips a fenced ```json block', () => {
|
|
const text = '```json\n{"a":1}\n```';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.deepEqual(JSON.parse(result), { a: 1 });
|
|
});
|
|
|
|
it('extracts a JSON object after leading prose', () => {
|
|
const text = 'Here are the findings:\n{"level":"critical"}';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.deepEqual(JSON.parse(result), { level: 'critical' });
|
|
});
|
|
|
|
it('extracts an array embedded in surrounding text', () => {
|
|
const text = 'prefix [1,2,3] suffix';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.deepEqual(JSON.parse(result), [1, 2, 3]);
|
|
});
|
|
|
|
it('returns an already-pure JSON string as-is', () => {
|
|
const text = '{"a":1,"b":[2,3]}';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.equal(result, '{"a":1,"b":[2,3]}');
|
|
assert.deepEqual(JSON.parse(result), { a: 1, b: [2, 3] });
|
|
});
|
|
|
|
it('returns the de-fenced original text when no valid JSON is found', () => {
|
|
const text = '```\nnot json at all\n```';
|
|
|
|
const result = extractJSONText(text);
|
|
|
|
assert.equal(result, 'not json at all');
|
|
});
|
|
});
|
|
|
|
describe('extractMeaningfulError', () => {
|
|
it('抽出尾端真正的錯誤,而非前段雜訊', () => {
|
|
const raw = [
|
|
'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, /access token revoked/);
|
|
assert.doesNotMatch(result, /trace:/);
|
|
});
|
|
|
|
it('抽不到錯誤行時退取尾段(不取開頭)', () => {
|
|
const raw = 'A'.repeat(1200) + '\nTAIL-CONTENT';
|
|
const result = extractMeaningfulError(raw, 100);
|
|
assert.ok(result.length <= 100);
|
|
assert.match(result, /TAIL-CONTENT$/);
|
|
});
|
|
|
|
it('容錯處理空輸入', () => {
|
|
assert.equal(extractMeaningfulError(''), '');
|
|
assert.equal(extractMeaningfulError(null), '');
|
|
});
|
|
});
|
|
|
|
describe('mapWithConcurrency', () => {
|
|
it('回傳與輸入同索引對應的結果(保序)', async () => {
|
|
const out = await mapWithConcurrency([1, 2, 3, 4], 2, async (n) => n * 10);
|
|
assert.deepEqual(out, [10, 20, 30, 40]);
|
|
});
|
|
|
|
it('遵守併發上限(同時執行數不超過 limit)', async () => {
|
|
let active = 0, peak = 0;
|
|
const wait = () => new Promise(r => setTimeout(r, 5));
|
|
await mapWithConcurrency([1, 2, 3, 4, 5, 6], 2, async () => {
|
|
active += 1; peak = Math.max(peak, active);
|
|
await wait();
|
|
active -= 1;
|
|
});
|
|
assert.ok(peak <= 2, `peak=${peak} 應 <= 2`);
|
|
});
|
|
|
|
it('limit 大於項目數時仍全部執行', async () => {
|
|
const out = await mapWithConcurrency(['a', 'b'], 10, async (s) => s.toUpperCase());
|
|
assert.deepEqual(out, ['A', 'B']);
|
|
});
|
|
|
|
it('limit<=0 表示不限制(全部同時並行)', async () => {
|
|
let active = 0, peak = 0;
|
|
const wait = () => new Promise(r => setTimeout(r, 5));
|
|
await mapWithConcurrency([1, 2, 3, 4, 5], 0, async () => {
|
|
active += 1; peak = Math.max(peak, active);
|
|
await wait();
|
|
active -= 1;
|
|
});
|
|
assert.equal(peak, 5, `peak=${peak} 應等於項目數(不限制)`);
|
|
});
|
|
|
|
it('空輸入回傳空陣列', async () => {
|
|
assert.deepEqual(await mapWithConcurrency([], 3, async () => 1), []);
|
|
assert.deepEqual(await mapWithConcurrency(null, 3, async () => 1), []);
|
|
});
|
|
});
|