fix(ai): handle Qwen topic reasoning and output limits

This commit is contained in:
digua committed 2026-09-22 18:36:13 +08:00
1 parent a67e38b816
commit b50c495e7d
8 files changed
+187 -5

No files matched your search

@@ -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.',
@@ -3,6 +3,12 @@
*/
export default {
ai: {
topics: {
reasoningLimitReached:
'モデルが出力上限に達しましたが、思考内容のみが返され、トピックの結果は生成されませんでした。高速モデルを変更するか、思考を無効にできるエンドポイントを使用してください。',
outputLimitReached:
'トピックの応答がモデルの出力上限に達し、内容が不完全な可能性があります。高速モデルを変更して再試行してください。',
},
tools: {
search_messages: {
desc: 'キーワードでチャット履歴を検索する。キーワード、メンバーの発言、特定トピックが出たかどうかなどの事実質問に対する主要な証拠取得ツール。時間範囲や送信者でメッセージをフィルタリングできる。分単位の精度で時間クエリをサポートする。',
@@ -3,6 +3,11 @@
*/
export default {
ai: {
topics: {
reasoningLimitReached:
'模型达到输出上限,但仅返回了思考内容,未生成话题结果。请更换快速模型,或使用支持关闭思考的接口。',
outputLimitReached: '话题结果达到模型输出上限,内容可能不完整。请更换快速模型后重试。',
},
tools: {
search_messages: {
desc: '根据关键词搜索聊天记录,是回答关键词、成员发言、是否出现过某话题等事实问题的主要证据工具。可以指定时间范围和发送者来筛选消息。支持精确到分钟级别的时间查询。',
@@ -3,6 +3,11 @@
*/
export default {
ai: {
topics: {
reasoningLimitReached:
'模型達到輸出上限,但僅回傳了思考內容,未產生話題結果。請更換快速模型,或使用支援關閉思考的介面。',
outputLimitReached: '話題結果達到模型輸出上限,內容可能不完整。請更換快速模型後重試。',
},
tools: {
search_messages: {
desc: '根據關鍵詞搜尋聊天紀錄,是回答關鍵詞、成員發言、是否出現過某話題等事實問題的主要證據工具。可以指定時間範圍和傳送者來篩選訊息。支援精確到分鐘級別的時間查詢。',
@@ -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<string, unknown> | 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<string, unknown>
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' },
@@ -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}`)
}
return {
text: result.content
const text = result.content
.filter((item): item is PiTextContent => item.type === 'text')
.map((item) => item.text)
.join(''),
.join('')
const outputLimit: ChatTopicModelResult['outputLimit'] =
result.stopReason === 'length'
? !text.trim() && result.content.some((item) => item.type === 'thinking')
? 'reasoning'
: 'text'
: undefined
return {
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<typeof buildPiModel>): 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.
@@ -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 = {
@@ -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) {