import * as assert from 'assert'; import { parseJsonResponse, chatWithRetry, setRetryBaseDelayForTest } from '../src/ai/engine'; import { EmptyContentError, ApiRequestError, isRetryableError } from '../src/ai/providers/base'; import type { AIProvider } from '../src/ai/providers/base'; import type { ChatOptions } from '../src/ai/providers/base'; import { OpenAICompatibleProvider } from '../src/ai/providers/openai-compatible'; const OPTIONS: ChatOptions = { model: 'test', temperature: 0, maxTokens: 1024, timeoutMs: 5000, }; suite('AI Empty Response Handling', () => { suiteSetup(() => { setRetryBaseDelayForTest(1); }); test('parseJsonResponse throws empty-response error on blank input', () => { assert.throws(() => parseJsonResponse(''), /空响应|empty response/); assert.throws(() => parseJsonResponse(' \n\t '), /空响应|empty response/); }); test('parseJsonResponse parses valid JSON normally', () => { const parsed = parseJsonResponse('{"findings":[]}') as { findings: unknown[] }; assert.deepStrictEqual(parsed.findings, []); }); test('chatWithRetry retries once on EmptyContentError', async () => { const calls: string[] = []; const provider = { chat: async (system: string): Promise => { calls.push(system); if (calls.length === 1) { throw new EmptyContentError('finish_reason=length'); } return '{"findings":[]}'; }, } as unknown as AIProvider; const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS); assert.strictEqual(result, '{"findings":[]}'); assert.strictEqual(calls.length, 2); }); test('chatWithRetry propagates error when retry also returns empty', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; throw new EmptyContentError('finish_reason=length'); }, } as unknown as AIProvider; await assert.rejects( () => chatWithRetry(provider, 'sys', 'user', OPTIONS), EmptyContentError ); assert.strictEqual(calls, 4); }); test('chatWithRetry does not retry on non-retryable errors', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; throw new Error('boom'); }, } as unknown as AIProvider; await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS), /boom/); assert.strictEqual(calls, 1); }); test('chatWithRetry retries on 429 and succeeds', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; if (calls < 3) { throw new ApiRequestError('API 请求失败 (429): rate limited', 429); } return '{"findings":[]}'; }, } as unknown as AIProvider; const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS); assert.strictEqual(result, '{"findings":[]}'); assert.strictEqual(calls, 3); }); test('chatWithRetry retries 429 up to 3 times then throws', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; throw new ApiRequestError('API 请求失败 (429): rate limited', 429); }, } as unknown as AIProvider; await assert.rejects( () => chatWithRetry(provider, 'sys', 'user', OPTIONS), (err: unknown) => err instanceof ApiRequestError ); assert.strictEqual(calls, 4); }); test('chatWithRetry fails immediately on 401', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; throw new ApiRequestError('API Key 无效', 401); }, } as unknown as AIProvider; await assert.rejects( () => chatWithRetry(provider, 'sys', 'user', OPTIONS), (err: unknown) => err instanceof ApiRequestError ); assert.strictEqual(calls, 1); }); test('chatWithRetry retries on network error (TypeError)', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; if (calls === 1) { throw new TypeError('fetch failed'); } return '{"findings":[]}'; }, } as unknown as AIProvider; const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS); assert.strictEqual(result, '{"findings":[]}'); assert.strictEqual(calls, 2); }); test('chatWithRetry retries on timeout (AbortError)', async () => { let calls = 0; const provider = { chat: async (): Promise => { calls++; if (calls === 1) { throw new DOMException('The operation was aborted', 'AbortError'); } return '{"findings":[]}'; }, } as unknown as AIProvider; const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS); assert.strictEqual(result, '{"findings":[]}'); assert.strictEqual(calls, 2); }); test('chatWithRetry backoff is exponential', async () => { setRetryBaseDelayForTest(10); try { const provider = { chat: async (): Promise => { throw new ApiRequestError('API 请求失败 (503)', 503); }, } as unknown as AIProvider; const start = Date.now(); await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS)); const elapsed = Date.now() - start; assert.ok(elapsed >= 70, `expected backoff >= 70ms, got ${elapsed}ms`); assert.ok(elapsed < 5000, `backoff too long: ${elapsed}ms`); } finally { setRetryBaseDelayForTest(1); } }); test('isRetryableError classifies error types', () => { assert.strictEqual(isRetryableError(new EmptyContentError('x')), true); assert.strictEqual(isRetryableError(new ApiRequestError('x', 429)), true); assert.strictEqual(isRetryableError(new ApiRequestError('x', 500)), true); assert.strictEqual(isRetryableError(new ApiRequestError('x', 503)), true); assert.strictEqual(isRetryableError(new ApiRequestError('x', 401)), false); assert.strictEqual(isRetryableError(new ApiRequestError('x', 400)), false); assert.strictEqual(isRetryableError(new ApiRequestError('x')), false); assert.strictEqual(isRetryableError(new TypeError('fetch failed')), true); assert.strictEqual(isRetryableError(new DOMException('abort', 'AbortError')), true); assert.strictEqual(isRetryableError(new Error('boom')), false); }); test('openai-compatible reports max_tokens truncation clearly', async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => ({ ok: true, json: async () => ({ choices: [{ message: { content: null }, finish_reason: 'length' }], }), text: async () => '', })) as unknown as typeof fetch; try { const provider = new OpenAICompatibleProvider('key', 'http://localhost', 'test', 'Test'); await assert.rejects( () => provider.chat('sys', 'user', { model: 'm', temperature: 0, maxTokens: 8192, timeoutMs: 5000, }), (err: unknown) => { assert.ok(err instanceof EmptyContentError); assert.match((err as Error).message, /max_tokens/); assert.match((err as Error).message, /8192/); return true; } ); } finally { globalThis.fetch = originalFetch; } }); });