diff --git a/packages/node-runtime/src/ai/i18n/locales/en-US.ts b/packages/node-runtime/src/ai/i18n/locales/en-US.ts index 5bead4c3..eeca6597 100644 --- a/packages/node-runtime/src/ai/i18n/locales/en-US.ts +++ b/packages/node-runtime/src/ai/i18n/locales/en-US.ts @@ -3,6 +3,12 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + 'The model reached its output limit with only reasoning and no topic result. Use a different fast model or an endpoint that supports disabling thinking.', + outputLimitReached: + 'The topic response reached the model output limit and may be incomplete. Use a different fast model and retry.', + }, tools: { search_messages: { desc: 'Search chat records by keywords. This is the primary evidence tool for factual questions about keywords, member messages, or whether a topic appeared. Can specify time range and sender to filter messages. Supports minute-level time queries.', diff --git a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts index d9336d0a..0ab59ce7 100644 --- a/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts +++ b/packages/node-runtime/src/ai/i18n/locales/ja-JP.ts @@ -3,6 +3,12 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + 'モデルが出力上限に達しましたが、思考内容のみが返され、トピックの結果は生成されませんでした。高速モデルを変更するか、思考を無効にできるエンドポイントを使用してください。', + outputLimitReached: + 'トピックの応答がモデルの出力上限に達し、内容が不完全な可能性があります。高速モデルを変更して再試行してください。', + }, tools: { search_messages: { desc: 'キーワードでチャット履歴を検索する。キーワード、メンバーの発言、特定トピックが出たかどうかなどの事実質問に対する主要な証拠取得ツール。時間範囲や送信者でメッセージをフィルタリングできる。分単位の精度で時間クエリをサポートする。', diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts index 463ae1e3..7268ea04 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-CN.ts @@ -3,6 +3,11 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + '模型达到输出上限,但仅返回了思考内容,未生成话题结果。请更换快速模型,或使用支持关闭思考的接口。', + outputLimitReached: '话题结果达到模型输出上限,内容可能不完整。请更换快速模型后重试。', + }, tools: { search_messages: { desc: '根据关键词搜索聊天记录,是回答关键词、成员发言、是否出现过某话题等事实问题的主要证据工具。可以指定时间范围和发送者来筛选消息。支持精确到分钟级别的时间查询。', diff --git a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts index cd8213a3..acf7139b 100644 --- a/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts +++ b/packages/node-runtime/src/ai/i18n/locales/zh-TW.ts @@ -3,6 +3,11 @@ */ export default { ai: { + topics: { + reasoningLimitReached: + '模型達到輸出上限,但僅回傳了思考內容,未產生話題結果。請更換快速模型,或使用支援關閉思考的介面。', + outputLimitReached: '話題結果達到模型輸出上限,內容可能不完整。請更換快速模型後重試。', + }, tools: { search_messages: { desc: '根據關鍵詞搜尋聊天紀錄,是回答關鍵詞、成員發言、是否出現過某話題等事實問題的主要證據工具。可以指定時間範圍和傳送者來篩選訊息。支援精確到分鐘級別的時間查詢。', diff --git a/packages/node-runtime/src/services/topics/model-client.test.ts b/packages/node-runtime/src/services/topics/model-client.test.ts index e71a4821..e1e84aba 100644 --- a/packages/node-runtime/src/services/topics/model-client.test.ts +++ b/packages/node-runtime/src/services/topics/model-client.test.ts @@ -1,9 +1,107 @@ import assert from 'node:assert/strict' import test from 'node:test' +import type { AssistantMessage } from '@earendil-works/pi-ai' import type { AIServiceConfig } from '../../ai' import { completeSimple } from '../../ai/pi-runtime' import { createChatTopicModelClient } from './model-client' +const anthropicConfig: AIServiceConfig = { + id: 'synthetic', + name: 'Synthetic', + provider: 'anthropic', + apiFormat: 'anthropic-messages', + model: 'qwen3.8-flash', + baseUrl: 'https://test.cn-beijing.maas.aliyuncs.com/apps/anthropic', + apiKey: 'test-key', + createdAt: 1, + updatedAt: 1, +} + +// Prevent provider-default thinking from consuming the topic result budget, without changing other APIs/models. +for (const { config, expectedThinking } of [ + { config: anthropicConfig, expectedThinking: { type: 'disabled' } }, + { + config: { ...anthropicConfig, provider: 'openai-compatible', baseUrl: 'https://example.invalid/anthropic/v1' }, + expectedThinking: { type: 'disabled' }, + }, + { config: { ...anthropicConfig, model: 'claude-sonnet-4-5' }, expectedThinking: undefined }, + { config: { ...anthropicConfig, apiFormat: 'openai-completions' as const }, expectedThinking: undefined }, +]) { + test(`topic thinking payload for ${config.provider}/${config.apiFormat}/${config.model}`, async () => { + let request: Record | undefined + const client = createChatTopicModelClient(config, { + completeSimple: (model, context, options) => + completeSimple(model, context, { + ...options, + onPayload: async (payload, model) => { + request = ((await options?.onPayload?.(payload, model)) ?? payload) as Record + throw new Error('Captured topic request before network') + }, + }), + }) + await assert.rejects( + () => + client.complete( + { systemPrompt: 'Return JSON only.', userPrompt: 'Synthetic messages' }, + { signal: new AbortController().signal, sessionId: 'synthetic' } + ), + /Captured topic request before network/ + ) + assert.ok(request) + assert.deepEqual(request.thinking, expectedThinking) + if (config.apiFormat === 'anthropic-messages') assert.equal(request.max_tokens, 4096) + }) +} + +// Truncated output must remain distinguishable from invalid JSON, while retaining billable usage. +for (const { content, stopReason, expectedLimit } of [ + { + content: [{ type: 'thinking', thinking: 'Synthetic reasoning' }], + stopReason: 'length', + expectedLimit: 'reasoning', + }, + { content: [{ type: 'text', text: '{"operations":' }], stopReason: 'length', expectedLimit: 'text' }, + { + content: [{ type: 'text', text: '{"operations":[],"assignments":[]}' }], + stopReason: 'stop', + expectedLimit: undefined, + }, +] satisfies Array<{ + content: AssistantMessage['content'] + stopReason: AssistantMessage['stopReason'] + expectedLimit?: string +}>) { + test(`topic response preserves ${stopReason}/${expectedLimit ?? 'complete'} and usage`, async () => { + const client = createChatTopicModelClient(anthropicConfig, { + completeSimple: async (model) => ({ + role: 'assistant', + content, + api: model.api, + provider: model.provider, + model: model.id, + usage: { + input: 120, + output: 4096, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 4216, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason, + timestamp: Date.now(), + }), + }) + const result = await client.complete( + { systemPrompt: 'Return JSON only.', userPrompt: 'Synthetic messages' }, + { signal: new AbortController().signal, sessionId: 'synthetic' } + ) + assert.equal(result.outputLimit, expectedLimit) + assert.equal(result.inputTokens, 120) + assert.equal(result.outputTokens, 4096) + assert.equal(result.text, content[0].type === 'text' ? content[0].text : '') + }) +} + // Prevent topic requests from failing with HTTP 400 on Qwen endpoints that reject the developer role. for (const { provider, model, expectedRole } of [ { provider: 'qwen', model: 'qwen3.8', expectedRole: 'system' }, diff --git a/packages/node-runtime/src/services/topics/model-client.ts b/packages/node-runtime/src/services/topics/model-client.ts index 0e0f8c17..c709e264 100644 --- a/packages/node-runtime/src/services/topics/model-client.ts +++ b/packages/node-runtime/src/services/topics/model-client.ts @@ -11,6 +11,7 @@ export interface ChatTopicModelResult { text: string inputTokens: number outputTokens: number + outputLimit?: 'reasoning' | 'text' } export interface ChatTopicModelClient { @@ -57,20 +58,35 @@ export function createChatTopicModelClient( if (result.stopReason === 'error' || result.stopReason === 'aborted') { throw new Error(result.errorMessage || `Topic model request ${result.stopReason}`) } + const text = result.content + .filter((item): item is PiTextContent => item.type === 'text') + .map((item) => item.text) + .join('') + const outputLimit: ChatTopicModelResult['outputLimit'] = + result.stopReason === 'length' + ? !text.trim() && result.content.some((item) => item.type === 'thinking') + ? 'reasoning' + : 'text' + : undefined return { - text: result.content - .filter((item): item is PiTextContent => item.type === 'text') - .map((item) => item.text) - .join(''), + text, inputTokens: result.usage.input, outputTokens: result.usage.output, + ...(outputLimit ? { outputLimit } : {}), } }, } } -function normalizeTopicModelPayload(payload: unknown, model: { provider: string; baseUrl: string }): unknown { - if (!isRecord(payload) || !isDeepSeekEndpoint(model)) return undefined +function normalizeTopicModelPayload(payload: unknown, model: ReturnType): unknown { + if (!isRecord(payload)) return undefined + + // Qwen's Anthropic-compatible endpoint may enable thinking when the field is omitted. + // Topic extraction needs JSON, not reasoning; keep this override out of interactive AI requests. + if (model.api === 'anthropic-messages' && /\bqwen/i.test(model.id)) { + return { ...payload, thinking: { type: 'disabled' } } + } + if (!isDeepSeekEndpoint(model)) return undefined // The DeepSeek chat-completions endpoint ignored max_completion_tokens in a real topic run and emitted more than // 15k output tokens. Keep this compatibility correction local to the topic runtime until the SDK fixes detection. diff --git a/packages/node-runtime/src/services/topics/service.test.ts b/packages/node-runtime/src/services/topics/service.test.ts index ca8f7dac..c66467a1 100644 --- a/packages/node-runtime/src/services/topics/service.test.ts +++ b/packages/node-runtime/src/services/topics/service.test.ts @@ -390,6 +390,45 @@ test('generation does not resume a checkpoint created in another timezone', asyn } }) +// Do not retry a truncated response as malformed JSON or replace an existing successful topic snapshot. +for (const outputLimit of ['reasoning', 'text'] as const) { + test(`truncated ${outputLimit} fails clearly, records usage, and preserves existing topics`, async () => { + let truncate = false + const modelClient: ChatTopicModelClient = { + modelId: 'test/model', + async complete(prompts) { + if (!truncate) return successfulTopicResult(prompts) + return { + text: outputLimit === 'reasoning' ? '' : '{"operations":[],"assignments":[]}', + inputTokens: 120, + outputTokens: 4096, + outputLimit, + } + }, + } + const { service, manager } = createHarness(modelClient, 1) + try { + const first = service.generateDay('group', '2026-08-09', 'Asia/Shanghai', 'en-US') + await waitForRun(service, 'group', first.id, 'completed') + const originalDay = service.getDay('group', '2026-08-09', 'Asia/Shanghai') + assert.ok(originalDay?.topics.length) + + truncate = true + const next = service.generateDay('group', '2026-08-09', 'Asia/Shanghai', 'en-US') + const failed = await waitForRun(service, 'group', next.id, 'failed') + assert.match(failed.lastError ?? '', /output limit/i) + if (outputLimit === 'reasoning') assert.match(failed.lastError ?? '', /reasoning/i) + assert.equal(failed.modelCalls, 1) + assert.equal(failed.inputTokens, 120) + assert.equal(failed.outputTokens, 4096) + assert.deepEqual(service.getDay('group', '2026-08-09', 'Asia/Shanghai'), originalDay) + } finally { + service.close() + manager.closeAll() + } + }) +} + test('a paused model request resumes from the persisted day checkpoint', async () => { let calls = 0 const modelClient: ChatTopicModelClient = { diff --git a/packages/node-runtime/src/services/topics/service.ts b/packages/node-runtime/src/services/topics/service.ts index 147aff54..ab9fec4b 100644 --- a/packages/node-runtime/src/services/topics/service.ts +++ b/packages/node-runtime/src/services/topics/service.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import type { PathProvider } from '@openchatlab/core' import type { ChatTopicDay, ChatTopicPreflight, ChatTopicRun, CreateChatTopicsRequest } from '@openchatlab/shared-types' import { appLogger } from '../../logging/app-logger' +import { createAiTranslate } from '../../ai/i18n' import { isRuntimeVersionAtLeast, raiseDataDirMinRuntimeVersion, type RuntimeIdentity } from '../../data-dir-compat' import type { SessionRuntimeAdapter } from '../adapters' import { @@ -542,6 +543,12 @@ export function createChatTopicService(deps: ChatTopicServiceDeps): ChatTopicSer } const response = await modelClient.complete(attemptPrompts, { signal, sessionId }) run = recordModelUsage(run, response) + if (response.outputLimit) { + const t = createAiTranslate(run.locale ?? undefined) + throw new Error( + t(response.outputLimit === 'reasoning' ? 'ai.topics.reasoningLimitReached' : 'ai.topics.outputLimitReached') + ) + } try { return { run, value: validate(response.text) } } catch (error) {