Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/core/application/workers/ConverterWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}
Expand Down
133 changes: 133 additions & 0 deletions src/core/application/workers/__tests__/ConverterWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()', () => {
Expand Down Expand Up @@ -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;

Expand Down
8 changes: 7 additions & 1 deletion src/core/domain/llm/ILLMClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 6 additions & 2 deletions src/core/infrastructure/adapters/llm/AnthropicClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompletionResponse> => {
const { done, value } = await reader.read();
Expand All @@ -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
};
}
Expand All @@ -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;
Expand Down Expand Up @@ -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 // 保留原始响应以便调试
};
Expand Down
2 changes: 1 addition & 1 deletion src/core/infrastructure/adapters/llm/GeminiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 // 保留原始响应以便调试
};
Expand Down
20 changes: 20 additions & 0 deletions src/core/infrastructure/adapters/llm/LLMClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Infrastructure layer - contains implementation logic
*/

import { FINISH_REASON_TRUNCATED } from '../../../domain/llm/ILLMClient.js';
import type {
ILLMClient,
CompletionOptions,
Expand All @@ -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
*/
Expand All @@ -43,6 +51,18 @@ export abstract class LLMClient implements ILLMClient {
*/
abstract completion(options: CompletionOptions): Promise<CompletionResponse>;

/**
* 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
Expand Down
4 changes: 2 additions & 2 deletions src/core/infrastructure/adapters/llm/OllamaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
Expand All @@ -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 // 保留原始响应以便调试
};
Expand Down
7 changes: 5 additions & 2 deletions src/core/infrastructure/adapters/llm/OpenAIClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CompletionResponse> => {
Expand All @@ -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
};
Expand All @@ -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;
Expand Down Expand Up @@ -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 // 保留原始响应以便调试
Expand Down
16 changes: 12 additions & 4 deletions src/core/infrastructure/adapters/llm/OpenAIResponsesClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: <type>\ndata: <json>\n\n
Expand Down Expand Up @@ -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') {
// 工具调用增量(流式工具调用)
Expand Down
Loading
Loading