From 592376b1d948b23662e393ef8975fb46a13ba72c Mon Sep 17 00:00:00 2001 From: CyberWolf Date: Thu, 5 Nov 2026 03:10:14 +0800 Subject: [PATCH] fix(llm): fail pages whose model output was truncated (#87) A page cut off by the output token limit was marked as converted, so the user got incomplete Markdown with nothing in the UI to show it. Ollama always reported `stop`, and ConverterWorker never read finishReason. - Report truncation with one value, FINISH_REASON_TRUNCATED, from all five clients (length / max_tokens / MAX_TOKENS / max_output_tokens). Other reasons pass through as before. - Read the reason the Responses API really uses, incomplete_details.reason, including the response.incomplete stream event. - ConverterWorker treats truncation as a retryable failure, so the reason reaches the existing page and task error display. - Clients don't throw on truncation, so the connection test with maxTokens: 8 keeps working. --- .../application/workers/ConverterWorker.ts | 10 + .../workers/__tests__/ConverterWorker.test.ts | 133 +++++++++++ src/core/domain/llm/ILLMClient.ts | 8 +- .../adapters/llm/AnthropicClient.ts | 8 +- .../adapters/llm/GeminiClient.ts | 2 +- .../infrastructure/adapters/llm/LLMClient.ts | 20 ++ .../adapters/llm/OllamaClient.ts | 4 +- .../adapters/llm/OpenAIClient.ts | 7 +- .../adapters/llm/OpenAIResponsesClient.ts | 16 +- .../llm/__tests__/FinishReason.test.ts | 225 ++++++++++++++++++ .../__tests__/completion.handler.test.ts | 11 + 11 files changed, 432 insertions(+), 12 deletions(-) create mode 100644 src/core/infrastructure/adapters/llm/__tests__/FinishReason.test.ts diff --git a/src/core/application/workers/ConverterWorker.ts b/src/core/application/workers/ConverterWorker.ts index 05cb659..b3a7a19 100644 --- a/src/core/application/workers/ConverterWorker.ts +++ b/src/core/application/workers/ConverterWorker.ts @@ -7,6 +7,7 @@ import { eventBus, TaskEventType } from '../../shared/events/EventBus.js'; import { prisma } from '../../infrastructure/db/index.js'; import { WORKER_CONFIG } from '../../infrastructure/config/worker.config.js'; import type { CompletionResponse } from '../../infrastructure/adapters/llm/LLMClient.js'; +import { FINISH_REASON_TRUNCATED } from '../../domain/llm/ILLMClient.js'; /** * Error types for classification and retry decisions. @@ -320,6 +321,15 @@ export class ConverterWorker extends WorkerBase { const outputTokens = this.extractOutputTokens(response); // Step 5: Validate content + // Truncation comes first: the output limit can also leave the content empty. + // Retryable: a model stuck repeating itself can finish normally on another attempt. + if (response.finishReason === FINISH_REASON_TRUNCATED) { + throw new Error( + 'LLM output was truncated at the output token limit, so the page content would be incomplete. ' + + 'Try again with a model or provider setting that allows longer output.' + ); + } + if (!response.content || response.content.trim().length === 0) { throw new Error('LLM returned empty content'); } diff --git a/src/core/application/workers/__tests__/ConverterWorker.test.ts b/src/core/application/workers/__tests__/ConverterWorker.test.ts index 64e27eb..ba4281b 100644 --- a/src/core/application/workers/__tests__/ConverterWorker.test.ts +++ b/src/core/application/workers/__tests__/ConverterWorker.test.ts @@ -61,6 +61,8 @@ vi.mock('../../../infrastructure/config/worker.config.js', () => ({ import { prisma } from '../../../infrastructure/db/index.js'; import modelLogic from '../../services/ModelService.js'; import { eventBus, TaskEventType } from '../../../shared/events/EventBus.js'; +import { OllamaClient } from '../../../infrastructure/adapters/llm/OllamaClient.js'; +import { FINISH_REASON_TRUNCATED } from '../../../domain/llm/ILLMClient.js'; describe('ConverterWorker', () => { let worker: ConverterWorker; @@ -294,6 +296,38 @@ describe('ConverterWorker', () => { await expect((worker as any).convertPage(mockPage)).rejects.toThrow('Content exceeds maximum length'); }); + + it('should throw error if the output was truncated', async () => { + vi.mocked(modelLogic.completion).mockResolvedValue({ + content: '# Partial', + finishReason: FINISH_REASON_TRUNCATED, + rawResponse: {}, + } as any); + + await expect((worker as any).convertPage(mockPage)).rejects.toThrow('truncated'); + }); + + it('should report truncation rather than empty content when the limit left nothing', async () => { + vi.mocked(modelLogic.completion).mockResolvedValue({ + content: '', + finishReason: FINISH_REASON_TRUNCATED, + rawResponse: {}, + } as any); + + await expect((worker as any).convertPage(mockPage)).rejects.toThrow('truncated'); + }); + + it.each(['stop', 'end_turn', 'STOP', undefined])('should accept finish reason %s', async (finishReason) => { + vi.mocked(modelLogic.completion).mockResolvedValue({ + content: '# Complete', + finishReason, + rawResponse: {}, + } as any); + + const result = await (worker as any).convertPage(mockPage); + + expect(result.markdown).toBe('# Complete'); + }); }); describe('cleanMarkdownContent()', () => { @@ -1041,6 +1075,105 @@ describe('ConverterWorker', () => { expect(modelLogic.completion).toHaveBeenCalledTimes(1); }); + describe('when the output is truncated', () => { + const stubPageTransaction = (pages: number) => { + const tx = { + taskDetail: { + findUnique: vi.fn().mockResolvedValue({ + worker_id: worker.getWorkerId(), + status: PageStatus.PROCESSING, + }), + update: vi.fn(), + }, + task: { + findUnique: vi.fn().mockResolvedValue({ + status: TaskStatus.PROCESSING, + pages, + completed_count: 0, + failed_count: 0, + }), + update: vi.fn().mockResolvedValue({ completed_count: 1, failed_count: 1 }), + }, + }; + vi.mocked(prisma.$transaction).mockImplementation(async (callback: any) => callback(tx)); + vi.mocked(prisma.task.findUnique).mockResolvedValue({ id: 'task123', status: TaskStatus.PROCESSING } as any); + vi.mocked(prisma.taskDetail.update).mockResolvedValue({} as any); + return tx; + }; + + const truncated = { content: '# Partial', finishReason: FINISH_REASON_TRUNCATED, rawResponse: {} } as any; + + it('should retry, then fail the page with a visible reason and without the partial content', async () => { + const tx = stubPageTransaction(10); + vi.mocked(modelLogic.completion).mockResolvedValue(truncated); + + await (worker as any).processPageWithRetry(mockPage); + + expect(modelLogic.completion).toHaveBeenCalledTimes(4); // first attempt + maxRetries + expect(tx.taskDetail.update).toHaveBeenCalledTimes(1); + const { data } = tx.taskDetail.update.mock.calls[0][0]; + expect(data.status).toBe(PageStatus.FAILED); + expect(data.error).toContain('truncated'); + expect(data.content).toBeUndefined(); + expect(tx.task.update).toHaveBeenCalledWith( + expect.objectContaining({ data: { failed_count: { increment: 1 } } }) + ); + expect(eventBus.emitTaskDetailEvent).toHaveBeenCalledWith( + TaskEventType.TASK_DETAIL_UPDATED, + expect.objectContaining({ status: PageStatus.FAILED }) + ); + }); + + it('should show the reason on the task when it is the only page', async () => { + const tx = stubPageTransaction(1); + vi.mocked(modelLogic.completion).mockResolvedValue(truncated); + + await (worker as any).processPageWithRetry(mockPage); + + expect(tx.task.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + status: TaskStatus.FAILED, + error: expect.stringContaining('truncated'), + }), + }) + ); + }); + + it('should complete the page when a retry finishes normally', async () => { + const tx = stubPageTransaction(10); + vi.mocked(modelLogic.completion) + .mockResolvedValueOnce(truncated) + .mockResolvedValueOnce({ content: '# Complete', finishReason: 'stop', rawResponse: {} } as any); + + await (worker as any).processPageWithRetry(mockPage); + + expect(modelLogic.completion).toHaveBeenCalledTimes(2); + expect(tx.taskDetail.update).toHaveBeenCalledTimes(1); + expect(tx.taskDetail.update.mock.calls[0][0].data).toMatchObject({ + status: PageStatus.COMPLETED, + content: '# Complete', + }); + }); + + it('should fail the page for a truncated response from a real client', async () => { + const tx = stubPageTransaction(10); + vi.mocked(modelLogic.completion).mockImplementation(async (_provider, options) => { + global.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ + model: 'llama3', + message: { role: 'assistant', content: '# Partial' }, + done: true, + done_reason: 'length', + }))); + return new OllamaClient('', 'http://localhost:11434/api/chat').completion(options); + }); + + await (worker as any).processPageWithRetry(mockPage); + + expect(tx.taskDetail.update.mock.calls[0][0].data.status).toBe(PageStatus.FAILED); + }); + }); + it('should stop retrying if task is cancelled', async () => { let callCount = 0; diff --git a/src/core/domain/llm/ILLMClient.ts b/src/core/domain/llm/ILLMClient.ts index cb109e7..39683a8 100644 --- a/src/core/domain/llm/ILLMClient.ts +++ b/src/core/domain/llm/ILLMClient.ts @@ -105,13 +105,19 @@ export interface ToolCall { }; } +/** + * Finish reason every client reports when the output was cut off by the token limit. + * Other finish reasons are passed through as the provider returned them. + */ +export const FINISH_REASON_TRUNCATED = 'length'; + /** * Completion response */ export interface CompletionResponse { content: string; model: string; - finishReason?: string; + finishReason?: string; // FINISH_REASON_TRUNCATED when the output hit the token limit toolCalls?: ToolCall[]; responseFormat?: 'text' | 'json_object'; rawResponse?: any; // Raw response for debugging diff --git a/src/core/infrastructure/adapters/llm/AnthropicClient.ts b/src/core/infrastructure/adapters/llm/AnthropicClient.ts index 24d4ca6..f9437ab 100644 --- a/src/core/infrastructure/adapters/llm/AnthropicClient.ts +++ b/src/core/infrastructure/adapters/llm/AnthropicClient.ts @@ -99,6 +99,7 @@ export class AnthropicClient extends LLMClient { const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let content = ''; + let finishReason: string | undefined; const processStream = async (): Promise => { const { done, value } = await reader.read(); @@ -107,7 +108,7 @@ export class AnthropicClient extends LLMClient { return { content, model: modelName, - finishReason: 'stop', + finishReason: this.normalizeFinishReason(finishReason) ?? 'stop', responseFormat: normalizedOptions.response_format?.type }; } @@ -123,6 +124,9 @@ export class AnthropicClient extends LLMClient { try { const data = JSON.parse(line.slice(6)); + // Claude 3在message_delta事件的delta中返回,Claude 2在顶层返回 + finishReason = data.delta?.stop_reason || data.stop_reason || finishReason; + // Claude 3使用content_block_delta类型流式传输内容 if (data.type === 'content_block_delta' && data.delta?.text) { content += data.delta.text; @@ -173,7 +177,7 @@ export class AnthropicClient extends LLMClient { return { content, model: data.model, - finishReason: data.stop_reason, + finishReason: this.normalizeFinishReason(data.stop_reason), responseFormat: normalizedOptions.response_format?.type, rawResponse: data // 保留原始响应以便调试 }; diff --git a/src/core/infrastructure/adapters/llm/GeminiClient.ts b/src/core/infrastructure/adapters/llm/GeminiClient.ts index adfa59e..5793da5 100644 --- a/src/core/infrastructure/adapters/llm/GeminiClient.ts +++ b/src/core/infrastructure/adapters/llm/GeminiClient.ts @@ -85,7 +85,7 @@ export class GeminiClient extends LLMClient { return { content, model: modelName, - finishReason: candidate.finishReason, + finishReason: this.normalizeFinishReason(candidate.finishReason), responseFormat: normalizedOptions.response_format?.type, rawResponse: data // 保留原始响应以便调试 }; diff --git a/src/core/infrastructure/adapters/llm/LLMClient.ts b/src/core/infrastructure/adapters/llm/LLMClient.ts index f30a7b9..e7471d7 100644 --- a/src/core/infrastructure/adapters/llm/LLMClient.ts +++ b/src/core/infrastructure/adapters/llm/LLMClient.ts @@ -3,6 +3,7 @@ * Infrastructure layer - contains implementation logic */ +import { FINISH_REASON_TRUNCATED } from '../../../domain/llm/ILLMClient.js'; import type { ILLMClient, CompletionOptions, @@ -26,6 +27,13 @@ export type { CompletionResponse, } from '../../../domain/llm/ILLMClient.js'; +/** + * Lowercased provider values meaning the output hit the token limit. + * Shared rather than per client: OpenAI- and Anthropic-compatible gateways don't always + * use the value of the protocol they implement. + */ +const TRUNCATION_FINISH_REASONS = new Set(['length', 'max_tokens', 'max_output_tokens']); + /** * LLM Client abstract base class */ @@ -43,6 +51,18 @@ export abstract class LLMClient implements ILLMClient { */ abstract completion(options: CompletionOptions): Promise; + /** + * Map the provider's truncation reasons to FINISH_REASON_TRUNCATED, leaving others as they are. + * Clients only report the reason: whether truncation is an error is up to the caller, + * as the connection test deliberately runs with a tiny maxTokens. + */ + protected normalizeFinishReason(reason?: string | null): string | undefined { + if (!reason) { + return undefined; + } + return TRUNCATION_FINISH_REASONS.has(reason.toLowerCase()) ? FINISH_REASON_TRUNCATED : reason; + } + /** * Normalize options for backward compatibility with single prompt * @param options Options that may contain old prompt format diff --git a/src/core/infrastructure/adapters/llm/OllamaClient.ts b/src/core/infrastructure/adapters/llm/OllamaClient.ts index 29408f9..9b60781 100644 --- a/src/core/infrastructure/adapters/llm/OllamaClient.ts +++ b/src/core/infrastructure/adapters/llm/OllamaClient.ts @@ -144,7 +144,7 @@ export class OllamaClient extends LLMClient { return { content, model: data.model || normalizedOptions.model || 'llama3', - finishReason: 'stop', + finishReason: this.normalizeFinishReason(data.done_reason) ?? 'stop', responseFormat: normalizedOptions.response_format?.type, rawResponse: data }; @@ -171,7 +171,7 @@ export class OllamaClient extends LLMClient { return { content: data.message.content || '', model: data.model, - finishReason: data.done ? 'stop' : undefined, + finishReason: data.done ? this.normalizeFinishReason(data.done_reason) ?? 'stop' : undefined, responseFormat: normalizedOptions.response_format?.type, rawResponse: data // 保留原始响应以便调试 }; diff --git a/src/core/infrastructure/adapters/llm/OpenAIClient.ts b/src/core/infrastructure/adapters/llm/OpenAIClient.ts index d8b3e45..adaf476 100644 --- a/src/core/infrastructure/adapters/llm/OpenAIClient.ts +++ b/src/core/infrastructure/adapters/llm/OpenAIClient.ts @@ -78,6 +78,7 @@ export class OpenAIClient extends LLMClient { const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let content = ''; + let finishReason: string | undefined; const toolCalls: ToolCall[] = []; const processStream = async (): Promise => { @@ -87,7 +88,7 @@ export class OpenAIClient extends LLMClient { return { content, model: normalizedOptions.model || 'gpt-3.5-turbo', - finishReason: 'stop', + finishReason: this.normalizeFinishReason(finishReason) ?? 'stop', toolCalls: toolCalls.length > 0 ? toolCalls : undefined, responseFormat: normalizedOptions.response_format?.type }; @@ -104,6 +105,8 @@ export class OpenAIClient extends LLMClient { try { const data = JSON.parse(line.slice(6)); + finishReason = data.choices?.[0]?.finish_reason || finishReason; + // 处理常规文本内容 if (data.choices && data.choices[0]?.delta?.content) { const newContent = data.choices[0].delta.content; @@ -193,7 +196,7 @@ export class OpenAIClient extends LLMClient { return { content: responseContent, model: data.model, - finishReason: data.choices[0]?.finish_reason, + finishReason: this.normalizeFinishReason(data.choices[0]?.finish_reason), toolCalls: toolCalls.length > 0 ? toolCalls : undefined, responseFormat: normalizedOptions.response_format?.type, rawResponse: data // 保留原始响应以便调试 diff --git a/src/core/infrastructure/adapters/llm/OpenAIResponsesClient.ts b/src/core/infrastructure/adapters/llm/OpenAIResponsesClient.ts index a6073e4..6192336 100644 --- a/src/core/infrastructure/adapters/llm/OpenAIResponsesClient.ts +++ b/src/core/infrastructure/adapters/llm/OpenAIResponsesClient.ts @@ -259,13 +259,21 @@ export class OpenAIResponsesClient extends LLMClient { return { content: responseContent, model: data.model, - finishReason: data.output?.[0]?.finish_reason || 'stop', + finishReason: this.resolveFinishReason(data) ?? 'stop', toolCalls: toolCalls.length > 0 ? toolCalls : undefined, responseFormat: options.response_format?.type, rawResponse: data // 保留原始响应以便调试 }; } + /** + * Responses API 用 status: "incomplete" 和 incomplete_details.reason 表示输出未完成 + * output 项上的 finish_reason 只有部分兼容网关返回 + */ + private resolveFinishReason(data: any): string | undefined { + return this.normalizeFinishReason(data.incomplete_details?.reason || data.output?.[0]?.finish_reason); + } + /** * 处理Responses API的流式响应 * 使用SSE格式: event: \ndata: \n\n @@ -354,11 +362,11 @@ export class OpenAIResponsesClient extends LLMClient { } } } - } else if (currentEvent === 'response.completed') { - // 响应完成 + } else if (currentEvent === 'response.completed' || currentEvent === 'response.incomplete') { + // 响应结束(输出被截断时是 response.incomplete) if (data.response) { model = data.response.model || model; - finishReason = data.response.output?.[0]?.finish_reason; + finishReason = this.resolveFinishReason(data.response); } } else if (currentEvent === 'response.tool_calls.delta') { // 工具调用增量(流式工具调用) diff --git a/src/core/infrastructure/adapters/llm/__tests__/FinishReason.test.ts b/src/core/infrastructure/adapters/llm/__tests__/FinishReason.test.ts new file mode 100644 index 0000000..e6ecef4 --- /dev/null +++ b/src/core/infrastructure/adapters/llm/__tests__/FinishReason.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { FINISH_REASON_TRUNCATED } from '../../../../domain/llm/ILLMClient.js' +import type { CompletionOptions, CompletionResponse } from '../LLMClient.js' +import { AnthropicClient } from '../AnthropicClient.js' +import { GeminiClient } from '../GeminiClient.js' +import { OllamaClient } from '../OllamaClient.js' +import { OpenAIClient } from '../OpenAIClient.js' +import { OpenAIResponsesClient } from '../OpenAIResponsesClient.js' + +const CONTENT = 'partial markdown' + +const messages: CompletionOptions['messages'] = [ + { role: 'user', content: { type: 'text', text: 'convert this page' } } +] + +const sse = (...events: unknown[]) => events.map(event => `data: ${JSON.stringify(event)}\n\n`).join('') + +const responsesEvent = (event: string, data: unknown) => `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` + +const responsesBody = (fields: Record) => ({ + model: 'gpt-4o', + output: [{ type: 'message', content: [{ type: 'text', text: CONTENT }] }], + ...fields +}) + +// The Responses API has no finish reason for a normal end, only status: "completed" +const responsesEnd = (reason: string) => reason === 'stop' + ? responsesBody({ status: 'completed', incomplete_details: null }) + : responsesBody({ status: 'incomplete', incomplete_details: { reason } }) + +interface ClientCase { + name: string + create: () => { completion(options: CompletionOptions): Promise } + /** Response body whose finish reason is the provider's own value */ + body: (reason: string) => unknown + truncatedReason: string + /** What the client reports for a normal end, which must stay as it is */ + normalReason: string + /** Streamed response ending with the provider's own value; absent if the client has no streaming */ + stream?: (reason: string) => string +} + +const clients: ClientCase[] = [ + { + name: 'OpenAI', + create: () => new OpenAIClient('key', 'https://api.test/chat/completions'), + body: reason => ({ + model: 'gpt-4o', + choices: [{ message: { role: 'assistant', content: CONTENT }, finish_reason: reason }] + }), + truncatedReason: 'length', + normalReason: 'stop', + stream: reason => sse( + { choices: [{ delta: { content: CONTENT }, finish_reason: null }] }, + { choices: [{ delta: {}, finish_reason: reason }] } + ) + }, + { + name: 'Anthropic', + create: () => new AnthropicClient('key', 'https://api.test/messages'), + body: reason => ({ + model: 'claude', + content: [{ type: 'text', text: CONTENT }], + stop_reason: reason + }), + truncatedReason: 'max_tokens', + normalReason: 'end_turn', + stream: reason => sse( + { type: 'content_block_delta', delta: { type: 'text_delta', text: CONTENT } }, + { type: 'message_delta', delta: { stop_reason: reason } } + ) + }, + { + name: 'Gemini', + create: () => new GeminiClient('key', 'https://api.test/models'), + body: reason => ({ + candidates: [{ content: { parts: [{ text: CONTENT }] }, finishReason: reason }] + }), + truncatedReason: 'MAX_TOKENS', + normalReason: 'STOP' + }, + { + name: 'Ollama', + create: () => new OllamaClient('', 'http://localhost:11434/api/chat'), + body: reason => ({ + model: 'llama3', + message: { role: 'assistant', content: CONTENT }, + done: true, + done_reason: reason + }), + truncatedReason: 'length', + normalReason: 'stop', + stream: reason => [ + { model: 'llama3', message: { role: 'assistant', content: CONTENT }, done: false }, + { model: 'llama3', message: { role: 'assistant', content: '' }, done: true, done_reason: reason } + ].map(line => JSON.stringify(line)).join('\n') + '\n' + }, + { + name: 'OpenAI Responses', + create: () => new OpenAIResponsesClient('key', 'https://api.test/responses'), + body: responsesEnd, + truncatedReason: 'max_output_tokens', + normalReason: 'stop', + stream: reason => responsesEvent('response.output_text.delta', { delta: CONTENT }) + + responsesEvent(reason === 'stop' ? 'response.completed' : 'response.incomplete', { response: responsesEnd(reason) }) + } +] + +const stubFetch = (body: string) => { + global.fetch = vi.fn().mockResolvedValue(new Response(body)) +} + +describe('finish reason mapping', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe.each(clients)('$name', ({ create, body, truncatedReason, normalReason }) => { + it('should keep the finish reason of a normal end', async () => { + stubFetch(JSON.stringify(body(normalReason))) + + const result = await create().completion({ messages }) + + expect(result.finishReason).toBe(normalReason) + }) + + it('should report truncation with the shared value, keeping the partial content', async () => { + stubFetch(JSON.stringify(body(truncatedReason))) + + const result = await create().completion({ messages }) + + expect(result.finishReason).toBe(FINISH_REASON_TRUNCATED) + expect(result.content).toBe(CONTENT) + }) + + it('should resolve rather than throw on truncation, as the connection test relies on it', async () => { + stubFetch(JSON.stringify(body(truncatedReason))) + + await expect(create().completion({ messages, maxTokens: 8 })).resolves.toBeDefined() + }) + }) + + describe.each(clients.filter(client => client.stream))('$name streaming', ({ create, stream, truncatedReason, normalReason }) => { + const completeStream = () => create().completion({ messages, stream: true, onUpdate: vi.fn() }) + + it('should report truncation with the shared value', async () => { + stubFetch(stream!(truncatedReason)) + + const result = await completeStream() + + expect(result.finishReason).toBe(FINISH_REASON_TRUNCATED) + expect(result.content).toBe(CONTENT) + }) + + it('should keep the finish reason of a normal end', async () => { + stubFetch(stream!(normalReason)) + + const result = await completeStream() + + expect(result.finishReason).toBe(normalReason) + }) + }) + + describe('Ollama without done_reason', () => { + it('should treat a finished response as stopped, as older servers omit the reason', async () => { + stubFetch(JSON.stringify({ model: 'llama3', message: { role: 'assistant', content: CONTENT }, done: true })) + + const result = await new OllamaClient('', 'http://localhost:11434/api/chat').completion({ messages }) + + expect(result.finishReason).toBe('stop') + }) + }) + + describe('OpenAI Responses end states', () => { + const client = () => new OpenAIResponsesClient('key', 'https://api.test/responses') + + it('should map a gateway that reports the reason on the output item', async () => { + stubFetch(JSON.stringify({ + model: 'gpt-4o', + output: [{ content: CONTENT, finish_reason: 'length' }] + })) + + const result = await client().completion({ messages }) + + expect(result.finishReason).toBe(FINISH_REASON_TRUNCATED) + }) + + it('should not report truncation when a response is incomplete for another reason', async () => { + stubFetch(JSON.stringify(responsesEnd('content_filter'))) + + const result = await client().completion({ messages }) + + expect(result.finishReason).toBe('content_filter') + }) + }) + + describe('gateways using the value of another protocol', () => { + it.each(['length', 'max_tokens', 'MAX_TOKENS', 'max_output_tokens'])( + 'should map %s from an OpenAI-compatible gateway', + async reason => { + stubFetch(JSON.stringify({ + model: 'gpt-4o', + choices: [{ message: { role: 'assistant', content: CONTENT }, finish_reason: reason }] + })) + + const result = await new OpenAIClient('key', 'https://api.test/chat/completions').completion({ messages }) + + expect(result.finishReason).toBe(FINISH_REASON_TRUNCATED) + } + ) + }) + + describe('reasons unrelated to the output limit', () => { + it.each(['tool_calls', 'content_filter'])('should pass %s through', async reason => { + stubFetch(JSON.stringify({ + model: 'gpt-4o', + choices: [{ message: { role: 'assistant', content: CONTENT }, finish_reason: reason }] + })) + + const result = await new OpenAIClient('key', 'https://api.test/chat/completions').completion({ messages }) + + expect(result.finishReason).toBe(reason) + }) + }) +}) diff --git a/src/main/ipc/handlers/__tests__/completion.handler.test.ts b/src/main/ipc/handlers/__tests__/completion.handler.test.ts index 8069a0e..ce6b4a2 100644 --- a/src/main/ipc/handlers/__tests__/completion.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/completion.handler.test.ts @@ -186,6 +186,17 @@ describe('Completion Handler', () => { ) }) + it('should succeed when the tiny maxTokens truncates the reply', async () => { + const truncated = { content: 'The', finishReason: 'length' } + mockModelLogic.completion.mockResolvedValue(truncated) + + const handler = handlers.get('completion:testConnection') + const result = await handler!({}, 1, 'gpt-4o') + + expect(mockModelLogic.completion).toHaveBeenCalledWith(1, expect.objectContaining({ maxTokens: 8 })) + expect(result).toEqual({ success: true, data: truncated }) + }) + it('should return error when providerId is missing', async () => { const handler = handlers.get('completion:testConnection') const result = await handler!({}, null, 'gpt-4o')