Prechádzať zdrojové kódy

fix(llm): 为 Qwen3.5/3.6 合并非首条 system 避免 Ollama 500 (#59)

Qwen3.5/3.6 的 chat template 禁止任何不在 index 0 的 system 消息。
Agent 会额外插入任务契约,导致本地 Ollama 返回 Jinja 500。
仅在 OpenAI 兼容组包中按模型名适配,其它模型 messages 原样出网。

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: darknessomi <darknessomi@users.noreply.github.com>
darknessomi 1 mesiac pred
rodič
commit
9d62a768df
2 zmenil súbory, kde vykonal 226 pridanie a 2 odobranie
  1. 160 0
      src/lib/llm-providers.spec.ts
  2. 66 2
      src/lib/llm-providers.ts

+ 160 - 0
src/lib/llm-providers.spec.ts

@@ -497,3 +497,163 @@ describe("prompt caching cache_control breakpoints", () => {
     expect(messages[0].content).toBe("AB")
   })
 })
+
+describe("Qwen3.5/3.6 leading system coalesce", () => {
+  const dualSystem = [
+    { role: "system" as const, content: "软件规则" },
+    { role: "system" as const, content: "## 任务契约\n写下一章" },
+    { role: "user" as const, content: "开始写" },
+  ]
+
+  function rolesAndContent(config: LlmConfig, messages = dualSystem) {
+    const body = getProviderConfig(config).buildBody(messages) as {
+      messages?: Array<{ role: string; content: unknown }>
+      input?: Array<{ role: string; content: unknown }>
+    }
+    return body.messages ?? body.input ?? []
+  }
+
+  it("does not invent a system message for user-only Qwen3.6 requests", () => {
+    const messages = rolesAndContent(
+      customConfig({ model: "qwen3.6-35b-q4km:latest" }),
+      [{ role: "user", content: "写第一章" }],
+    )
+    expect(messages).toEqual([{ role: "user", content: "写第一章" }])
+  })
+
+  it("leaves a single leading system in place", () => {
+    const messages = rolesAndContent(
+      customConfig({ model: "qwen3.6-plus" }),
+      [
+        { role: "system", content: "软件规则" },
+        { role: "user", content: "开始写" },
+      ],
+    )
+    expect(messages).toEqual([
+      { role: "system", content: "软件规则" },
+      { role: "user", content: "开始写" },
+    ])
+  })
+
+  it("merges consecutive leading systems for Qwen3.6 OpenAI-compatible requests", () => {
+    const messages = rolesAndContent(customConfig({ model: "qwen3.6-35b-q4km:latest" }))
+    expect(messages).toEqual([
+      { role: "system", content: "软件规则\n\n## 任务契约\n写下一章" },
+      { role: "user", content: "开始写" },
+    ])
+  })
+
+  it("merges a mid-conversation system into the leading system for Qwen3.6", () => {
+    const messages = rolesAndContent(
+      customConfig({ model: "qwen/qwen3.6-27b" }),
+      [
+        { role: "system", content: "软件规则" },
+        { role: "user", content: "写" },
+        { role: "assistant", content: "好" },
+        { role: "system", content: "必须调用工具" },
+      ],
+    )
+    expect(messages.map((message) => message.role)).toEqual(["system", "user", "assistant"])
+    expect(messages[0]?.content).toBe("软件规则\n\n必须调用工具")
+    expect(messages[1]?.content).toBe("写")
+    expect(messages[2]?.content).toBe("好")
+  })
+
+  it("moves a non-leading system to the front for Qwen3.5", () => {
+    const messages = rolesAndContent(
+      customConfig({ model: "qwen3.5:397b" }),
+      [
+        { role: "user", content: "写" },
+        { role: "system", content: "软件规则" },
+      ],
+    )
+    expect(messages).toEqual([
+      { role: "system", content: "软件规则" },
+      { role: "user", content: "写" },
+    ])
+  })
+
+  it("keeps cache-prefixed system text before later system content", () => {
+    const messages = rolesAndContent(
+      customConfig({ model: "qwen3.6-plus" }),
+      [
+        {
+          role: "system",
+          content: [
+            { type: "text", text: "软件规则" },
+            { type: "text", text: "稳定项目核心", cacheControl: true },
+          ],
+        },
+        { role: "system", content: "任务契约" },
+        { role: "user", content: "写" },
+      ],
+    )
+    expect(messages).toHaveLength(2)
+    expect(messages[0]).toEqual({
+      role: "system",
+      content: "软件规则稳定项目核心\n\n任务契约",
+    })
+    expect(messages[1]?.role).toBe("user")
+  })
+
+  it("also coalesces Qwen3.6 on the native ollama provider", () => {
+    const messages = rolesAndContent(customConfig({
+      provider: "ollama",
+      model: "qwen3.6-35b-q4km:latest",
+    }))
+    expect(messages.map((message) => message.role)).toEqual(["system", "user"])
+    expect(messages[0]?.content).toContain("软件规则")
+    expect(messages[0]?.content).toContain("任务契约")
+  })
+
+  it("coalesces Qwen3.6 Responses API input the same way", () => {
+    const messages = rolesAndContent(customConfig({
+      apiMode: "responses",
+      model: "qwen3.6-plus",
+    }))
+    expect(messages).toEqual([
+      { role: "system", content: "软件规则\n\n## 任务契约\n写下一章" },
+      { role: "user", content: "开始写" },
+    ])
+  })
+
+  it("does not coalesce dual systems for gpt-4o on openai or custom", () => {
+    for (const config of [
+      customConfig({ provider: "openai", model: "gpt-4o" }),
+      customConfig({ model: "gpt-4o" }),
+    ]) {
+      const messages = rolesAndContent(config)
+      expect(messages.map((message) => message.role)).toEqual(["system", "system", "user"])
+      expect(messages[0]?.content).toBe("软件规则")
+      expect(messages[1]?.content).toBe("## 任务契约\n写下一章")
+    }
+  })
+
+  it("does not coalesce dual systems for DeepSeek", () => {
+    const messages = rolesAndContent(customConfig({
+      model: "deepseek-chat",
+      customEndpoint: "https://api.deepseek.com/v1",
+    }))
+    expect(messages.map((message) => message.role)).toEqual(["system", "system", "user"])
+  })
+
+  it("does not coalesce dual systems for plain Qwen3", () => {
+    const messages = rolesAndContent(customConfig({ model: "qwen3-235b-a22b" }))
+    expect(messages.map((message) => message.role)).toEqual(["system", "system", "user"])
+  })
+
+  it("does not coalesce dual systems for Qwen3-Coder or Qwen2.5", () => {
+    for (const model of ["qwen3-coder-plus", "qwen2.5-72b"]) {
+      const messages = rolesAndContent(customConfig({ model }))
+      expect(messages.map((message) => message.role), model).toEqual(["system", "system", "user"])
+    }
+  })
+
+  it("does not coalesce gpt Responses API input", () => {
+    const messages = rolesAndContent(customConfig({
+      apiMode: "responses",
+      model: "gpt-5.4",
+    }))
+    expect(messages.map((message) => message.role)).toEqual(["system", "system", "user"])
+  })
+})

+ 66 - 2
src/lib/llm-providers.ts

@@ -506,6 +506,64 @@ function toOpenAiContent(content: string | ContentBlock[]): unknown {
   })
 }
 
+/**
+ * Qwen3.5 / Qwen3.6 chat templates raise
+ * `System message must be at the beginning` when any `role=system`
+ * message is not index 0. That includes a second consecutive leading
+ * system. Do not reuse `isChatTemplateThinkingModel` — that matches
+ * every Qwen3 id, including qwen3-coder / qwen3-235b which do not
+ * ship this guard.
+ */
+function needsStrictLeadingSystemTemplate(model: string): boolean {
+  return /qwen[-_./]?3[._-]?[56](?:\b|[._+:-]|$)/i.test(model)
+}
+
+function flattenSystemText(content: ChatMessage["content"]): string {
+  if (typeof content === "string") return content
+  return content.map((block) => (block.type === "text" ? block.text : "")).join("")
+}
+
+function mergeSystemContents(systems: ChatMessage[]): ChatMessage["content"] {
+  const usesBlocks = systems.some((message) => Array.isArray(message.content))
+  if (!usesBlocks) {
+    return systems
+      .map((message) => flattenSystemText(message.content))
+      .filter((text) => text.length > 0)
+      .join("\n\n")
+  }
+  const blocks: ContentBlock[] = []
+  for (const [index, message] of systems.entries()) {
+    if (typeof message.content === "string") {
+      if (!message.content) continue
+      blocks.push({
+        type: "text",
+        text: index > 0 && blocks.length > 0 ? `\n\n${message.content}` : message.content,
+      })
+      continue
+    }
+    if (index > 0 && blocks.length > 0 && message.content.length > 0) {
+      blocks.push({ type: "text", text: "\n\n" })
+    }
+    for (const block of message.content) {
+      blocks.push(block)
+    }
+  }
+  return blocks
+}
+
+/**
+ * Fold every system message into a single leading system entry so
+ * Qwen3.5/3.6 templates accept the payload. Leaves user / assistant /
+ * tool order unchanged. Does not invent a system message when none exist.
+ */
+function coalesceSystemMessages(messages: ChatMessage[]): ChatMessage[] {
+  const systems = messages.filter((message) => message.role === "system")
+  if (systems.length === 0) return messages
+  if (systems.length === 1 && messages[0]?.role === "system") return messages
+  const rest = messages.filter((message) => message.role !== "system")
+  return [{ role: "system", content: mergeSystemContents(systems) }, ...rest]
+}
+
 function buildOpenAiBody(
   messages: ChatMessage[],
   overrides?: RequestOverrides,
@@ -547,9 +605,12 @@ function buildResponsesBody(
   messages: ChatMessage[],
   overrides?: RequestOverrides,
 ): Record<string, unknown> {
+  const wiredMessages = needsStrictLeadingSystemTemplate(config.model)
+    ? coalesceSystemMessages(messages)
+    : messages
   const body: Record<string, unknown> = {
     model: config.model,
-    input: messages.map((message) => ({
+    input: wiredMessages.map((message) => ({
       role: message.role,
       content: toResponsesContent(message.content),
     })),
@@ -781,9 +842,12 @@ function buildOpenAiCompatibleBody(
   overrides?: RequestOverrides,
 ): Record<string, unknown> {
   const reasoning = effectiveReasoning(config, overrides)
+  const wiredMessages = needsStrictLeadingSystemTemplate(config.model)
+    ? coalesceSystemMessages(messages)
+    : messages
   // Pass full overrides: buildOpenAiBody strips internal/wire-agnostic
   // fields (including tools/toolChoice) then re-emits tools + tool_choice.
-  const body: Record<string, unknown> = buildOpenAiBody(messages, overrides)
+  const body: Record<string, unknown> = buildOpenAiBody(wiredMessages, overrides)
   if (
     config.provider === "openai"
     || config.provider === "azure"