fix(ai): preserve system role for Qwen chat completions

This commit is contained in:
digua committed 2026-09-22 10:51:11 +08:00
1 parent 14c9defdf1
commit 2e385bd401
3 files changed
+62 -4

No files matched your search

@@ -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', () => {
+5 -1
View File
@@ -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,
}
}
@@ -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<typeof import('../../ai').completeSimple>[2]
let normalizedPayload: unknown