Procházet zdrojové kódy

fix(llm): 修复小米MiMo思考上限问题并增加max_tokens输出保护

- 将 isQwenThinkingModel 重命名为 isChatTemplateThinkingModel 并扩展正则
  覆盖 MiMo 模型,修复 MiMo 未被识别为思考模型导致自动重试时
  enable_thinking=false 参数从未发送的致命问题
- 新增 isMiMoEndpoint 通过 xiaomimimo.com 端点双重检测
- 新增 GLM-5+ 智谱模型思考控制预防性支持(模型名+bigmodel.cn端点双重门控)
- 新增 thinkingMinMaxTokens/ensureMinMaxTokens,为所有 OpenAI 兼容思考模型
  (DeepSeek/Qwen3/MiMo/GLM-5+)在思考显式开启时自动提升 max_tokens,
  防止思考耗尽输出配额导致正文为空,与 Anthropic 路径保护对齐
- 新增 15 个测试用例覆盖 MiMo/GLM 识别与 max_tokens 保护
- 版本号升级至 3.1.5
Mochocyang před 4 týdny
rodič
revize
2a3f9f1ce1

+ 1 - 1
package.json

@@ -1,7 +1,7 @@
 {
   "name": "qmai",
   "private": true,
-  "version": "3.1.4",
+  "version": "3.1.5",
   "license": "GPL-3.0-or-later",
   "type": "module",
   "scripts": {

+ 1 - 1
src-tauri/Cargo.lock

@@ -5869,7 +5869,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
 
 [[package]]
 name = "qmai"
-version = "3.1.3"
+version = "3.1.5"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "qmai"
-version = "3.1.4"
+version = "3.1.5"
 description = "QMAI - AI writing system for long-form novels"
 authors = ["Mochocyang"]
 edition = "2021"

+ 1 - 1
src-tauri/tauri.conf.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://schema.tauri.app/config/2",
   "productName": "QMaiWrite",
-  "version": "3.1.4",
+  "version": "3.1.5",
   "identifier": "com.qingmuai.writer",
   "build": {
     "beforeDevCommand": "npm run dev",

+ 14 - 0
src/lib/changelog.ts

@@ -7,6 +7,19 @@ export interface ChangelogEntry {
   };
 }
 
+const THREE_POINT_ONE_FIVE_CHANGELOG: ChangelogEntry = {
+  version: "3.1.5",
+  date: "2026-08-12",
+  highlights: {
+    en: [
+      "[MiMo Thinking Fix] Fixed Xiaomi MiMo models hitting the thinking token cap and producing no response. MiMo is now correctly recognized as a chat-template thinking model, so the auto-retry with thinking disabled works properly. Also added max_tokens protection for all OpenAI-compatible thinking models (Qwen3, MiMo, DeepSeek, GLM-5+) to ensure the response has enough room for both reasoning and answer.",
+    ],
+    zh: [
+      "【小米 MiMo 思考上限修复】修复使用小米 MiMo 模型时频繁提示「思考上限」、只输出思考内容不出正文的问题。MiMo 现在被正确识别为 chat_template_kwargs 类型思考模型,自动关闭思考重试可正常生效;同时为所有 OpenAI 兼容思考模型(Qwen3、MiMo、DeepSeek、GLM-5+)增加 max_tokens 输出保护,确保思考和正文都有足够 token 空间,不再因思考耗尽输出配额而空响应",
+    ],
+  },
+};
+
 const THREE_POINT_ONE_TWO_CHANGELOG: ChangelogEntry = {
   version: "3.1.2",
   date: "2026-08-08",
@@ -1057,6 +1070,7 @@ function isMergedOnePointRelease(version: string): boolean {
 }
 
 export const CHANGELOG: ChangelogEntry[] = [
+  THREE_POINT_ONE_FIVE_CHANGELOG,
   THREE_POINT_ONE_TWO_CHANGELOG,
   {
     version: "1.0.7",

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

@@ -68,6 +68,148 @@ describe("llm provider reasoning options", () => {
     expect(body).not.toHaveProperty("reasoning_effort")
   })
 
+  it("enables MiMo thinking via model name when reasoning is enabled", () => {
+    const body = requestBody(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "high" },
+    }))
+
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: true })
+  })
+
+  it("disables MiMo thinking via model name when reasoning is off", () => {
+    const body = requestBody(customConfig({
+      model: "MiMo-v2-pro",
+      reasoning: { mode: "off" },
+    }))
+
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: false })
+  })
+
+  it("enables MiMo thinking via xiaomimimo.com endpoint even with custom model name", () => {
+    const body = requestBody(customConfig({
+      model: "custom-model-alias",
+      customEndpoint: "https://api.xiaomimimo.com/v1",
+      reasoning: { mode: "high" },
+    }))
+
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: true })
+  })
+
+  it("disables MiMo thinking via xiaomimimo.com endpoint when reasoning is off", () => {
+    const body = requestBody(customConfig({
+      model: "some-alias",
+      customEndpoint: "https://token-plan-cn.xiaomimimo.com/v1",
+      reasoning: { mode: "off" },
+    }))
+
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: false })
+  })
+
+  it("enables GLM-5 thinking on bigmodel.cn when reasoning is enabled", () => {
+    const body = requestBody(customConfig({
+      model: "glm-5-plus",
+      customEndpoint: "https://open.bigmodel.cn/api/paas/v4",
+      reasoning: { mode: "high" },
+    }))
+
+    expect(body.thinking).toEqual({ type: "enabled" })
+  })
+
+  it("disables GLM-5 thinking on bigmodel.cn when reasoning is off", () => {
+    const body = requestBody(customConfig({
+      model: "GLM-5",
+      customEndpoint: "https://open.bigmodel.cn/api/paas/v4",
+      reasoning: { mode: "off" },
+    }))
+
+    expect(body.thinking).toEqual({ type: "disabled" })
+  })
+
+  it("does not send GLM thinking object for non-Zhipu endpoints", () => {
+    const body = requestBody(customConfig({
+      model: "glm-5-self-hosted",
+      customEndpoint: "https://my-vllm.example.com/v1",
+      reasoning: { mode: "high" },
+    }))
+
+    expect(body).not.toHaveProperty("thinking")
+  })
+
+  it("boosts max_tokens for MiMo when thinking is enabled without explicit max_tokens", () => {
+    const body = requestBody(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "high" },
+    }))
+
+    expect(body.max_tokens).toBe(16384)
+  })
+
+  it("boosts max_tokens for MiMo via endpoint detection", () => {
+    const body = requestBody(customConfig({
+      model: "custom-alias",
+      customEndpoint: "https://token-plan-cn.xiaomimimo.com/v1",
+      reasoning: { mode: "medium" },
+    }))
+
+    expect(body.max_tokens).toBe(8192)
+  })
+
+  it("does not override explicit larger max_tokens for MiMo thinking", () => {
+    const body = requestBody(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "high" },
+    }))
+    // Build body with explicit max_tokens override
+    const bodyWithOverride = getProviderConfig(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "high" },
+    })).buildBody(
+      [{ role: "user", content: "test" }],
+      { max_tokens: 32000 },
+    ) as Record<string, unknown>
+
+    expect(bodyWithOverride.max_tokens).toBe(32000)
+    expect(body.max_tokens).toBe(16384)
+  })
+
+  it("does not set max_tokens for MiMo when thinking is off", () => {
+    const body = requestBody(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "off" },
+    }))
+
+    expect(body).not.toHaveProperty("max_tokens")
+  })
+
+  it("does not set max_tokens for MiMo in auto mode", () => {
+    const body = requestBody(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "auto" },
+    }))
+
+    expect(body).not.toHaveProperty("max_tokens")
+  })
+
+  it("boosts max_tokens for Qwen3 thinking at medium level", () => {
+    const body = requestBody(customConfig({
+      model: "qwen3-235b-a22b",
+      reasoning: { mode: "medium" },
+    }))
+
+    expect(body.max_tokens).toBe(8192)
+  })
+
+  it("boosts max_tokens for DeepSeek thinking at low level", () => {
+    const body = requestBody(customConfig({
+      model: "deepseek-v4-flash",
+      reasoning: { mode: "low" },
+    }))
+
+    expect(body.thinking).toEqual({ type: "enabled" })
+    expect(body.max_tokens).toBe(4096)
+  })
+
   it.each<ReasoningMode>(["max", "custom"])("maps Responses API %s reasoning to high effort", (mode) => {
     const body = requestBody(customConfig({
       apiMode: "responses",

+ 96 - 3
src/lib/llm-providers.ts

@@ -602,6 +602,43 @@ function reasoningEffort(reasoning: ReasoningConfig): "low" | "medium" | "high"
   return null
 }
 
+/**
+ * Minimum total output tokens (thinking + final answer) required when
+ * chain-of-thought is explicitly enabled.  Without this floor the API's
+ * default `max_tokens` can be too small to hold both the reasoning trace
+ * and the final content — the model spends every token on `reasoning_content`
+ * and produces zero `content`, which surfaces as the "思考上限" error.
+ *
+ * Mirrors the protection already present in `buildAnthropicBodyWithReasoning`
+ * (budget_tokens + 4096 answer reserve).
+ */
+function thinkingMinMaxTokens(reasoning: ReasoningConfig): number {
+  switch (reasoning.mode) {
+    case "low":
+      return 4096
+    case "medium":
+      return 8192
+    case "high":
+    case "max":
+      return 16384
+    case "custom":
+      if (reasoning.budgetTokens !== undefined) {
+        return reasoning.budgetTokens + 4096
+      }
+      return 8192
+    default:
+      return 0
+  }
+}
+
+function ensureMinMaxTokens(body: Record<string, unknown>, min: number): void {
+  if (min <= 0) return
+  const current = body.max_tokens
+  if (typeof current !== "number" || current < min) {
+    body.max_tokens = min
+  }
+}
+
 function isDeepSeekEndpoint(config: LlmConfig): boolean {
   return /deepseek/i.test(config.model) || /deepseek/i.test(config.customEndpoint)
 }
@@ -625,8 +662,50 @@ export function getEffectiveMaxContextSize(config: LlmConfig): number {
   return config.maxContextSize || 204_800
 }
 
-function isQwenThinkingModel(model: string): boolean {
-  return /qwen[-_]?3/i.test(model)
+/**
+ * Models that control chain-of-thought via the vLLM/SGLang-standard
+ * `chat_template_kwargs.enable_thinking` boolean.
+ *
+ * Covers:
+ *  - Qwen3 / Qwen3.5 / Qwen3.6 / Qwen3-Coder  (qwen3 prefix)
+ *  - Xiaomi MiMo v2.x  (mimo-v2-pro, mimo-v2.5-pro, mimo-v2-flash, …)
+ *
+ * When `enable_thinking` is false the model suppresses reasoning_content
+ * entirely; when true it streams CoT through that field before the
+ * final `content`. Third-party vLLM gateways serving these models use
+ * the same parameter, so model-name matching is sufficient.
+ */
+function isChatTemplateThinkingModel(model: string): boolean {
+  return /qwen[-_]?3/i.test(model) || /mimo/i.test(model)
+}
+
+/**
+ * Endpoint-level fallback for Xiaomi MiMo. If the user kept the default
+ * model name but changed nothing else, model-name matching already
+ * catches it. This also covers custom model ids on the MiMo Token Plan
+ * gateways (api.xiaomimimo.com / token-plan-cn.xiaomimimo.com).
+ */
+function isMiMoEndpoint(config: LlmConfig): boolean {
+  return /xiaomimimo\.com/i.test(config.customEndpoint)
+}
+
+/**
+ * GLM-5+ models on the official Zhipu BigModel API control thinking
+ * through a top-level `thinking` object: `{ type: "enabled" }` or
+ * `{ type: "disabled" }`. GLM-4.x and earlier do not support this
+ * parameter — sending it would cause a 400, so the version gate matters.
+ *
+ * Third-party GLM deployments (Atlas Cloud, NVIDIA NIM, vLLM self-host)
+ * may use a different convention; we only apply this adaptation to the
+ * official bigmodel.cn endpoint.
+ */
+function isGLMThinkingModel(model: string): boolean {
+  return /glm[-_]?5/i.test(model)
+}
+
+function isZhipuEndpoint(config: LlmConfig): boolean {
+  return /bigmodel\.cn/i.test(config.customEndpoint)
+    || /(^|[/:.])zhipu([/:.]|$)/i.test(config.customEndpoint)
 }
 
 function isKimiEndpoint(config: LlmConfig): boolean {
@@ -703,6 +782,7 @@ function buildOpenAiCompatibleBody(
       body.thinking = { type: "disabled" }
     } else if (reasoning.mode !== "auto") {
       body.thinking = { type: "enabled" }
+      ensureMinMaxTokens(body, thinkingMinMaxTokens(reasoning))
       const effort = reasoningEffort(reasoning)
       if (effort) {
         body.reasoning_effort = effort
@@ -711,11 +791,24 @@ function buildOpenAiCompatibleBody(
     return body
   }
 
-  if (isQwenThinkingModel(config.model)) {
+  // chat_template_kwargs 类型思考模型(Qwen3、MiMo)
+  // 同时检查模型名称和端点URL,双重保险确保MiMo等模型被正确识别
+  if (isChatTemplateThinkingModel(config.model) || isMiMoEndpoint(config)) {
     if (reasoning.mode === "off") {
       body.chat_template_kwargs = { enable_thinking: false }
     } else if (reasoning.mode !== "auto") {
       body.chat_template_kwargs = { enable_thinking: true }
+      ensureMinMaxTokens(body, thinkingMinMaxTokens(reasoning))
+    }
+  }
+
+  // GLM-5+ 模型(智谱BigModel官方端点)使用顶层 thinking 对象控制思考
+  if (isGLMThinkingModel(config.model) && isZhipuEndpoint(config)) {
+    if (reasoning.mode === "off") {
+      body.thinking = { type: "disabled" }
+    } else if (reasoning.mode !== "auto") {
+      body.thinking = { type: "enabled" }
+      ensureMinMaxTokens(body, thinkingMinMaxTokens(reasoning))
     }
   }