From 2e385bd40188f4e09d98977319c35bbc197d3290 Mon Sep 17 00:00:00 2001 From: digua Date: Tue, 22 Sep 2026 10:39:10 +0800 Subject: [PATCH] fix(ai): preserve system role for Qwen chat completions --- .../src/ai/__tests__/llm-builder.test.ts | 6 +-- packages/node-runtime/src/ai/llm-builder.ts | 6 ++- .../src/services/topics/model-client.test.ts | 54 +++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts index 8300615b..dc8dfffa 100644 --- a/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts +++ b/packages/node-runtime/src/ai/__tests__/llm-builder.test.ts @@ -121,7 +121,7 @@ describe('buildPiModel', () => { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', }) assert.equal(model.reasoning, true) - assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen', supportsDeveloperRole: false }) }) it('auto-infers reasoning=true and compat.thinkingFormat=qwen for custom self-hosted qwen3 model', () => { @@ -131,7 +131,7 @@ describe('buildPiModel', () => { { findModelFn: () => null } ) assert.equal(model.reasoning, true) - assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen', supportsDeveloperRole: false }) }) it('auto-infers reasoning=true for custom model saved with only capabilities:["chat"]', () => { @@ -155,7 +155,7 @@ describe('buildPiModel', () => { } ) assert.equal(model.reasoning, true) - assert.deepEqual(model.compat, { thinkingFormat: 'qwen' }) + assert.deepEqual(model.compat, { thinkingFormat: 'qwen', supportsDeveloperRole: false }) }) it('auto-infers reasoning=false and compat=undefined for non-reasoning Anthropic model', () => { diff --git a/packages/node-runtime/src/ai/llm-builder.ts b/packages/node-runtime/src/ai/llm-builder.ts index 89bf4d7f..1e86aac8 100644 --- a/packages/node-runtime/src/ai/llm-builder.ts +++ b/packages/node-runtime/src/ai/llm-builder.ts @@ -156,6 +156,10 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption : baseUrl const { reasoning, compat, thinkingLevelMap } = inferReasoning(config.provider, modelId, modelDef) + // Qwen chat-completions endpoints reject developer messages, even for reasoning models. + // Override Pi's default for official and custom-hosted Qwen models without disabling thinking. + const requiresSystemRole = + apiFormat === 'openai-completions' && (config.provider.toLowerCase() === 'qwen' || /\bqwen|qwq/i.test(modelId)) return { id: modelId, @@ -171,6 +175,6 @@ export function buildPiModel(config: PiModelConfig, options?: BuildPiModelOption contextWindow, // Reasoning and visible text share the output budget on compatible APIs. maxTokens: config.maxTokens ?? (reasoning ? 16_384 : 4096), - compat, + compat: requiresSystemRole ? { ...compat, supportsDeveloperRole: false } : compat, } } 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 9e7bc81f..e71a4821 100644 --- a/packages/node-runtime/src/services/topics/model-client.test.ts +++ b/packages/node-runtime/src/services/topics/model-client.test.ts @@ -1,8 +1,62 @@ import assert from 'node:assert/strict' import test from 'node:test' import type { AIServiceConfig } from '../../ai' +import { completeSimple } from '../../ai/pi-runtime' import { createChatTopicModelClient } from './model-client' +// 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' }, + { provider: 'openai-compatible', model: 'qwen3.8', expectedRole: 'system' }, + { provider: 'openai-compatible', model: 'Qwen/QwQ-32B', expectedRole: 'system' }, + { provider: 'openai', model: 'o3', expectedRole: 'developer' }, +]) { + test(`topic requests preserve the supported prompt role for ${provider}/${model}`, async () => { + let messages: Array<{ role: string; content: unknown }> | undefined + let reasoning: boolean | undefined + const client = createChatTopicModelClient( + { + id: 'synthetic', + name: 'Synthetic', + provider, + model, + baseUrl: 'https://example.invalid/v1', + apiKey: 'test-key', + createdAt: 1, + updatedAt: 1, + }, + { + completeSimple: (model, context, options) => { + reasoning = model.reasoning + return completeSimple(model, context, { + ...options, + onPayload: async (payload, model) => { + const normalized = (await options?.onPayload?.(payload, model)) ?? payload + messages = (normalized as { messages: typeof messages }).messages + // Capture the real SDK serialization, then stop before any network request. + throw new Error('Captured topic request before network') + }, + }) + }, + } + ) + await assert.rejects( + () => + client.complete( + { systemPrompt: 'Synthetic system prompt', userPrompt: 'Synthetic user prompt' }, + { signal: new AbortController().signal, sessionId: 'synthetic' } + ), + /Captured topic request before network/ + ) + assert.equal(reasoning, true) + assert.deepEqual( + messages?.map((message) => message.role), + [expectedRole, 'user'] + ) + assert.equal(messages?.[0].content, 'Synthetic system prompt') + }) +} + test('topic model calls disable reasoning and constrain the DeepSeek payload', async () => { let capturedOptions: Parameters[2] let normalizedPayload: unknown