Ver código fonte

fix(llm): 纠正 token 窗口单位并增加输出上限配置

去掉预算层对 maxContextSize 的 /4,按规格表 token 窗口规划;新增 maxOutputTokens
滑块,思考档位放不下时改为关闭而非抬高 max_tokens,并修正 SSE/system 裁剪回归。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 4 semanas atrás
pai
commit
40e558ba78
35 arquivos alterados com 1081 adições e 383 exclusões
  1. 2 2
      package-lock.json
  2. 13 7
      src/components/settings/context-size-selector.tsx
  3. 11 3
      src/components/settings/llm-presets.ts
  4. 87 0
      src/components/settings/output-tokens-selector.tsx
  5. 36 1
      src/components/settings/preset-resolver.spec.ts
  6. 13 5
      src/components/settings/preset-resolver.ts
  7. 28 4
      src/components/settings/sections/custom-provider-cards.tsx
  8. 41 3
      src/components/settings/sections/llm-provider-section.tsx
  9. 16 0
      src/components/sources/outline-chat-panel.tsx
  10. 7 1
      src/i18n/en.json
  11. 7 1
      src/i18n/zh.json
  12. 16 5
      src/lib/agent/runner.ts
  13. 2 2
      src/lib/agent/tools/index.ts
  14. 29 0
      src/lib/chat-request-budget.test.ts
  15. 14 3
      src/lib/chat-request-budget.ts
  16. 2 7
      src/lib/context-budget.contract.spec.ts
  17. 3 3
      src/lib/context-budget.spec.ts
  18. 139 98
      src/lib/context-budget.test.ts
  19. 104 117
      src/lib/context-budget.ts
  20. 1 1
      src/lib/context-hub/composer.ts
  21. 1 1
      src/lib/context-hub/types.ts
  22. 12 1
      src/lib/env-llm-defaults.ts
  23. 22 21
      src/lib/ingest.prompt.test.ts
  24. 25 18
      src/lib/ingest.ts
  25. 36 11
      src/lib/llm-client.ts
  26. 73 5
      src/lib/llm-client.usage.spec.ts
  27. 42 7
      src/lib/llm-context-size.ts
  28. 56 15
      src/lib/llm-providers.spec.ts
  29. 66 34
      src/lib/llm-providers.ts
  30. 38 2
      src/lib/novel/deep-chapter-generation.spec.ts
  31. 12 0
      src/lib/novel/deep-chapter-generation.ts
  32. 6 2
      src/lib/novel/model-resolver.ts
  33. 47 0
      src/lib/project-store.integration.test.ts
  34. 68 2
      src/lib/project-store.ts
  35. 6 1
      src/stores/wiki-store.ts

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "qmai",
-  "version": "3.1.0",
+  "version": "3.1.5",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "qmai",
-      "version": "3.1.0",
+      "version": "3.1.5",
       "license": "GPL-3.0-or-later",
       "dependencies": {
         "@base-ui/react": "^1.7.0",

+ 13 - 7
src/components/settings/context-size-selector.tsx

@@ -1,16 +1,19 @@
+import { useTranslation } from "react-i18next"
 import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 export const CONTEXT_PRESETS = [
   { value: 204800, label: "200K" },
   { value: 262144, label: "256K" },
+  { value: 307200, label: "300K" },
+  { value: 409600, label: "400K" },
   { value: 524288, label: "512K" },
   { value: 1000000, label: "1M" },
 ]
 
-function formatSize(chars: number): string {
-  if (chars >= 1000000) return `${(chars / 1000000).toFixed(1)}M characters`
-  if (chars >= 1000) return `${Math.round(chars / 1000)}K characters`
-  return `${chars} characters`
+function formatSize(tokens: number): string {
+  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
+  if (tokens >= 1024) return `${Math.round(tokens / 1024)}K`
+  return String(tokens)
 }
 
 export function ContextSizeSelector({
@@ -20,6 +23,7 @@ export function ContextSizeSelector({
   value: number
   onChange: (v: number) => void
 }) {
+  const { t } = useTranslation()
   const normalizedValue = normalizeUserLlmContextSize(value)
   const closestIndex = CONTEXT_PRESETS.reduce((best, preset, i) => {
     return Math.abs(preset.value - normalizedValue) < Math.abs(CONTEXT_PRESETS[best].value - normalizedValue)
@@ -31,9 +35,8 @@ export function ContextSizeSelector({
   return (
     <div>
       <div className="flex items-center justify-between mb-2">
-        <span className="text-sm font-medium">{formatSize(normalizedValue)}</span>
-        <span className="text-xs text-muted-foreground">
-          ~{Math.floor((normalizedValue * 0.6) / 1000)}K chars for wiki content
+        <span className="text-sm font-medium">
+          {t("settings.sections.llm.contextWindowValue", { value: formatSize(normalizedValue) })}
         </span>
       </div>
       <input
@@ -62,6 +65,9 @@ export function ContextSizeSelector({
           </button>
         ))}
       </div>
+      <p className="text-[10px] text-muted-foreground mt-1">
+        {t("settings.sections.llm.contextWindowHint")}
+      </p>
     </div>
   )
 }

+ 11 - 3
src/components/settings/llm-presets.ts

@@ -57,8 +57,15 @@ export interface LlmPreset {
   suggestedModels?: string[]
   /** Custom providers only: which wire protocol to speak. */
   apiMode?: CustomApiMode
-  /** Suggested character-budget window; user can override. */
+  /** Suggested context window in tokens, from the model's spec sheet; user can override. */
   suggestedContextSize?: number
+  /**
+   * Suggested maximum output in tokens, from the model's spec sheet; user can
+   * override. Only fill this in where the figure has a source — a wrong value
+   * here either wastes the model's capacity or gets the request rejected.
+   * Omitted presets fall back to `DEFAULT_USER_LLM_MAX_OUTPUT_TOKENS`.
+   */
+  suggestedMaxOutputTokens?: number
 }
 
 const RAW_LLM_PRESETS: LlmPreset[] = [
@@ -213,6 +220,8 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
       "deepseek-reasoner",
     ],
     suggestedContextSize: 1000000,
+    // DeepSeek-V4: 1000K context / 384K max output, per the published spec.
+    suggestedMaxOutputTokens: 393216,
   },
   {
     id: "atlascloud",
@@ -251,8 +260,7 @@ const RAW_LLM_PRESETS: LlmPreset[] = [
     baseUrl: "https://api.groq.com/openai/v1",
     defaultModel: "llama-3.3-70b-versatile",
     apiMode: "chat_completions",
-    // The writing floor is 204800 characters, approximately 51200 model
-    // tokens. Models below that real context window are not suggested.
+    // Writing workflows require at least 204800 tokens of context.
     suggestedModels: [
       "llama-3.3-70b-versatile",
       "llama-3.1-8b-instant",

+ 87 - 0
src/components/settings/output-tokens-selector.tsx

@@ -0,0 +1,87 @@
+import { useTranslation } from "react-i18next"
+import { normalizeUserLlmMaxOutputTokens } from "@/lib/llm-context-size"
+
+export const OUTPUT_TOKEN_PRESETS = [
+  { value: 65536, label: "64K" },
+  { value: 131072, label: "128K" },
+  { value: 262144, label: "256K" },
+  { value: 393216, label: "384K" },
+]
+
+function formatTokens(tokens: number): string {
+  if (tokens >= 1000) return `${Math.round(tokens / 1024)}K`
+  return String(tokens)
+}
+
+/**
+ * Declares how much the selected model can emit in one response — a capability
+ * ceiling, not a request size. Each workflow asks for what it needs and this
+ * only ever caps it, so raising the slider does not make responses longer.
+ */
+export function OutputTokensSelector({
+  value,
+  contextWindow,
+  onChange,
+}: {
+  value: number | undefined
+  contextWindow?: number
+  onChange: (v: number) => void
+}) {
+  const { t } = useTranslation()
+  const normalizedValue = normalizeUserLlmMaxOutputTokens(value)
+  const closestIndex = OUTPUT_TOKEN_PRESETS.reduce((best, preset, i) => {
+    return Math.abs(preset.value - normalizedValue) < Math.abs(OUTPUT_TOKEN_PRESETS[best].value - normalizedValue)
+      ? i
+      : best
+  }, 0)
+  const pct = (closestIndex / (OUTPUT_TOKEN_PRESETS.length - 1)) * 100
+  // Output and input share one window. Spec sheets often list both as the same
+  // size (e.g. Doubao 256K/256K); that is a valid capability, not overflow.
+  // Only warn when the declared ceiling is strictly larger than the window.
+  const exceedsWindow =
+    typeof contextWindow === "number" && contextWindow > 0 && normalizedValue > contextWindow
+
+  return (
+    <div>
+      <div className="flex items-center justify-between mb-2">
+        <span className="text-sm font-medium">
+          {t("settings.sections.llm.maxOutputTokensValue", { value: formatTokens(normalizedValue) })}
+        </span>
+        {exceedsWindow ? (
+          <span className="text-xs text-amber-600 dark:text-amber-500">
+            {t("settings.sections.llm.maxOutputTokensExceedsWindow")}
+          </span>
+        ) : null}
+      </div>
+      <input
+        type="range"
+        min={0}
+        max={OUTPUT_TOKEN_PRESETS.length - 1}
+        step={1}
+        value={closestIndex}
+        onChange={(e) => onChange(OUTPUT_TOKEN_PRESETS[parseInt(e.target.value)].value)}
+        className="w-full h-2 rounded-lg appearance-none cursor-pointer accent-primary"
+        style={{
+          background: `linear-gradient(to right, #4f46e5 ${pct}%, #e5e7eb ${pct}%)`,
+        }}
+      />
+      <div className="flex justify-between mt-1">
+        {OUTPUT_TOKEN_PRESETS.map((preset, i) => (
+          <button
+            key={preset.value}
+            type="button"
+            onClick={() => onChange(preset.value)}
+            className={`text-[9px] px-0.5 ${
+              i === closestIndex ? "text-primary font-bold" : "text-muted-foreground/50"
+            }`}
+          >
+            {preset.label}
+          </button>
+        ))}
+      </div>
+      <p className="text-[10px] text-muted-foreground mt-1">
+        {t("settings.sections.llm.maxOutputTokensHint")}
+      </p>
+    </div>
+  )
+}

+ 36 - 1
src/components/settings/preset-resolver.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from "vitest"
 import { resolveConfig } from "./preset-resolver"
-import type { LlmPreset } from "./llm-presets"
+import { LLM_PRESETS, type LlmPreset } from "./llm-presets"
 import type { LlmConfig } from "@/stores/wiki-store"
 
 const fallback: LlmConfig = {
@@ -36,3 +36,38 @@ describe("resolveConfig functionCallingEnabled", () => {
     expect(config.functionCallingEnabled).toBe(false)
   })
 })
+
+describe("resolveConfig context and output limits", () => {
+  const deepseekPreset = LLM_PRESETS.find((preset) => preset.id === "deepseek")!
+
+  it("keeps the DeepSeek suggestion when the user has not chosen a window", () => {
+    const config = resolveConfig(deepseekPreset, { apiKey: "sk" }, fallback)
+    expect(config.maxContextSize).toBe(1_000_000)
+    expect(config.maxOutputTokens).toBe(393_216)
+  })
+
+  it("does not override a window the user set explicitly", () => {
+    // The window used to be forced back up to 1M here, so the DeepSeek slider
+    // looked adjustable but never took effect.
+    const config = resolveConfig(
+      deepseekPreset,
+      { apiKey: "sk", maxContextSize: 262_144 },
+      fallback,
+    )
+    expect(config.maxContextSize).toBe(262_144)
+  })
+
+  it("falls back to the default output limit for presets without a published figure", () => {
+    const config = resolveConfig(customPreset, { apiKey: "sk", model: "m" }, fallback)
+    expect(config.maxOutputTokens).toBe(131_072)
+  })
+
+  it("prefers an explicit output limit over the preset suggestion", () => {
+    const config = resolveConfig(
+      deepseekPreset,
+      { apiKey: "sk", maxOutputTokens: 65_536 },
+      fallback,
+    )
+    expect(config.maxOutputTokens).toBe(65_536)
+  })
+})

+ 13 - 5
src/components/settings/preset-resolver.ts

@@ -1,9 +1,11 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { ProviderOverride } from "@/stores/wiki-store"
 import { AZURE_OPENAI_API_VERSION } from "@/lib/azure-openai"
-import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
 import type { LlmPreset } from "./llm-presets"
-import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
+import {
+  normalizeUserLlmContextSize,
+  normalizeUserLlmMaxOutputTokens,
+} from "@/lib/llm-context-size"
 
 /**
  * Build a full LlmConfig from a preset template + the user's saved
@@ -21,6 +23,9 @@ export function resolveConfig(
   const rawMaxContextSize = normalizeUserLlmContextSize(
     ov.maxContextSize ?? preset.suggestedContextSize ?? fallback.maxContextSize,
   )
+  const rawMaxOutputTokens = normalizeUserLlmMaxOutputTokens(
+    ov.maxOutputTokens ?? preset.suggestedMaxOutputTokens ?? fallback.maxOutputTokens,
+  )
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
   const functionCallingEnabled = ov.functionCallingEnabled !== false
@@ -39,6 +44,7 @@ export function resolveConfig(
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "",
       maxContextSize: rawMaxContextSize,
+      maxOutputTokens: rawMaxOutputTokens,
       apiMode: ov.apiMode ?? preset.apiMode ?? "chat_completions",
       reasoning,
       localCliIsolation: false,
@@ -52,6 +58,7 @@ export function resolveConfig(
       ollamaUrl: ov.baseUrl ?? preset.baseUrl ?? "http://localhost:11434",
       customEndpoint: fallback.customEndpoint,
       maxContextSize: rawMaxContextSize,
+      maxOutputTokens: rawMaxOutputTokens,
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
@@ -66,6 +73,7 @@ export function resolveConfig(
       azureApiVersion: ov.azureApiVersion ?? preset.azureApiVersion ?? AZURE_OPENAI_API_VERSION,
       azureModelFamily: ov.azureModelFamily ?? preset.azureModelFamily ?? "auto",
       maxContextSize: rawMaxContextSize,
+      maxOutputTokens: rawMaxOutputTokens,
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
@@ -82,6 +90,7 @@ export function resolveConfig(
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: fallback.customEndpoint,
       maxContextSize: rawMaxContextSize,
+      maxOutputTokens: rawMaxOutputTokens,
       reasoning,
       localCliIsolation,
       codexCliTimeoutMinutes: preset.provider === "codex-cli" ? codexCliTimeoutMinutes : undefined,
@@ -97,6 +106,7 @@ export function resolveConfig(
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: ov.baseUrl ?? preset.baseUrl ?? "http://127.0.0.1:8765/v1",
       maxContextSize: rawMaxContextSize,
+      maxOutputTokens: rawMaxOutputTokens,
       apiMode: "chat_completions",
       reasoning,
       localCliIsolation: false,
@@ -113,14 +123,12 @@ export function resolveConfig(
       ollamaUrl: fallback.ollamaUrl,
       customEndpoint: fallback.customEndpoint,
       maxContextSize: rawMaxContextSize,
+      maxOutputTokens: rawMaxOutputTokens,
       reasoning,
       localCliIsolation: false,
       functionCallingEnabled,
     }
   }
 
-  // Apply model-specific context size minimums (e.g. DeepSeek → 1M)
-  config.maxContextSize = getEffectiveMaxContextSize(config)
-
   return config
 }

+ 28 - 4
src/components/settings/sections/custom-provider-cards.tsx

@@ -5,12 +5,21 @@ import { Input } from "@/components/ui/input"
 import { Label } from "@/components/ui/label"
 import { useWikiStore, type ProviderOverride, type SavedModel, type ReasoningConfig } from "@/stores/wiki-store"
 import { ContextSizeSelector } from "../context-size-selector"
+import { OutputTokensSelector } from "../output-tokens-selector"
 import { resolveConfig } from "../preset-resolver"
 import { fetchLlmModelList } from "@/lib/settings-model-list"
 import { useBatchModelTest } from "../hooks/use-batch-model-test"
 import { useTranslation } from "react-i18next"
-import { FunctionCallingControls, ReasoningControls } from "./llm-provider-section"
-import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
+import {
+  FunctionCallingControls,
+  ReasoningControls,
+  withOutputRoomForReasoning,
+} from "./llm-provider-section"
+import {
+  MIN_USER_LLM_CONTEXT_SIZE,
+  normalizeUserLlmContextSize,
+  normalizeUserLlmMaxOutputTokens,
+} from "@/lib/llm-context-size"
 
 interface CustomProviderCard {
   id: string
@@ -20,6 +29,7 @@ interface CustomProviderCard {
   apiKey: string
   model: string
   maxContextSize?: number
+  maxOutputTokens?: number
   reasoning?: ReasoningConfig
   functionCallingEnabled?: boolean
   enabled: boolean
@@ -45,6 +55,7 @@ export function CustomProviderCards() {
         apiKey: config.apiKey || "",
         model: config.model || "",
         maxContextSize: normalizeUserLlmContextSize(config.maxContextSize),
+        maxOutputTokens: config.maxOutputTokens,
         reasoning: config.reasoning,
         functionCallingEnabled: config.functionCallingEnabled,
         enabled: config.enabled ?? true,
@@ -101,6 +112,9 @@ export function CustomProviderCards() {
       maxContextSize: normalizeUserLlmContextSize(
         updates.maxContextSize ?? prev.maxContextSize,
       ),
+      maxOutputTokens: normalizeUserLlmMaxOutputTokens(
+        updates.maxOutputTokens ?? prev.maxOutputTokens,
+      ),
       reasoning: updates.reasoning ?? prev.reasoning,
       functionCallingEnabled: updates.functionCallingEnabled ?? prev.functionCallingEnabled,
       enabled: updates.enabled ?? prev.enabled ?? true,
@@ -706,15 +720,25 @@ function CustomProviderCardItem({
           <div className="space-y-2">
             <Label className="text-xs">{t("settings.sections.llm.contextWindow")}</Label>
             <ContextSizeSelector
-              value={card.maxContextSize ?? 131072}
+              value={card.maxContextSize ?? MIN_USER_LLM_CONTEXT_SIZE}
               onChange={(v) => onUpdate({ maxContextSize: v })}
             />
           </div>
 
+          {/* Output ceiling */}
+          <div className="space-y-2">
+            <Label className="text-xs">{t("settings.sections.llm.maxOutputTokens")}</Label>
+            <OutputTokensSelector
+              value={card.maxOutputTokens}
+              contextWindow={card.maxContextSize ?? MIN_USER_LLM_CONTEXT_SIZE}
+              onChange={(v) => onUpdate({ maxOutputTokens: v })}
+            />
+          </div>
+
           {/* Reasoning / thinking */}
           <ReasoningControls
             value={card.reasoning ?? { mode: "auto" }}
-            onChange={(reasoning) => onUpdate({ reasoning })}
+            onChange={(next) => onUpdate(withOutputRoomForReasoning(next, card.maxOutputTokens))}
           />
 
           <FunctionCallingControls

+ 41 - 3
src/components/settings/sections/llm-provider-section.tsx

@@ -7,6 +7,7 @@ import { Label } from "@/components/ui/label"
 import { useWikiStore, type ProviderOverride, type ReasoningConfig, type ReasoningMode, type SavedModel } from "@/stores/wiki-store"
 import { LLM_PRESETS, type LlmPreset } from "../llm-presets"
 import { ContextSizeSelector } from "../context-size-selector"
+import { OutputTokensSelector } from "../output-tokens-selector"
 import { resolveConfig } from "../preset-resolver"
 import { normalizeEndpoint } from "@/lib/endpoint-normalizer"
 import { isTauri } from "@/lib/platform"
@@ -17,7 +18,32 @@ import { useBatchModelTest } from "../hooks/use-batch-model-test"
 import { ModelSelectInput } from "../model-select-input"
 import { SavedModelsManager } from "./saved-models-manager"
 import { CustomProviderCards } from "./custom-provider-cards"
-import { normalizeProviderOverride } from "@/lib/llm-context-size"
+import {
+  MIN_USER_LLM_CONTEXT_SIZE,
+  normalizeProviderOverride,
+  normalizeUserLlmMaxOutputTokens,
+} from "@/lib/llm-context-size"
+import { thinkingMinMaxTokens } from "@/lib/llm-providers"
+
+/**
+ * Raise the declared output ceiling when the chosen reasoning level needs more
+ * room than it currently allows.
+ *
+ * Thinking and the final answer share one output allowance. When the ceiling is
+ * too low the request layer drops thinking rather than silently inflating
+ * `max_tokens` past what the model accepts, so the fix belongs here: adjust the
+ * user's own setting, at the moment they change the level, where they can see
+ * and undo it.
+ */
+export function withOutputRoomForReasoning(
+  reasoning: ReasoningConfig,
+  currentMaxOutputTokens: number | undefined,
+): ProviderOverride {
+  const required = thinkingMinMaxTokens(reasoning)
+  const current = normalizeUserLlmMaxOutputTokens(currentMaxOutputTokens)
+  if (required <= current) return { reasoning }
+  return { reasoning, maxOutputTokens: normalizeUserLlmMaxOutputTokens(required) }
+}
 
 export function LlmProviderSection() {
   const { t } = useTranslation()
@@ -174,7 +200,10 @@ function PresetRow({
   const baseUrl = ov.baseUrl ?? preset.baseUrl ?? ""
   const azureApiVersion = ov.azureApiVersion ?? preset.azureApiVersion ?? AZURE_OPENAI_API_VERSION
   const azureModelFamily = ov.azureModelFamily ?? preset.azureModelFamily ?? "auto"
-  const context = ov.maxContextSize ?? preset.suggestedContextSize ?? 131072
+  const context = ov.maxContextSize ?? preset.suggestedContextSize ?? MIN_USER_LLM_CONTEXT_SIZE
+  const maxOutputTokens = normalizeUserLlmMaxOutputTokens(
+    ov.maxOutputTokens ?? preset.suggestedMaxOutputTokens,
+  )
   const reasoning = ov.reasoning ?? { mode: "auto" as const }
   const localCliIsolation = ov.localCliIsolation === true
   const codexCliTimeoutMinutes = Math.max(1, Math.min(240, ov.codexCliTimeoutMinutes ?? 10))
@@ -696,9 +725,18 @@ function PresetRow({
             />
           </div>
 
+          <div className="space-y-2">
+            <Label>{t("settings.sections.llm.maxOutputTokens")}</Label>
+            <OutputTokensSelector
+              value={maxOutputTokens}
+              contextWindow={context}
+              onChange={(v) => onChange({ maxOutputTokens: v })}
+            />
+          </div>
+
           <ReasoningControls
             value={reasoning}
-            onChange={(reasoning) => onChange({ reasoning })}
+            onChange={(next) => onChange(withOutputRoomForReasoning(next, maxOutputTokens))}
           />
 
           <FunctionCallingControls

+ 16 - 0
src/components/sources/outline-chat-panel.tsx

@@ -118,6 +118,10 @@ import {
   planOutlineRequestBudget,
   type OutlineBudgetStage,
 } from "@/lib/context-budget";
+import {
+  getEffectiveMaxOutputTokens,
+  thinkingMinMaxTokens,
+} from "@/lib/llm-providers";
 import { ChatModelSelector } from "@/components/chat/chat-model-selector";
 import { highlightCode } from "@/lib/streaming-code-highlight";
 import { separateThinking } from "@/lib/separate-thinking";
@@ -1834,6 +1838,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         maxContextSize: effectiveLlmConfig.maxContextSize,
         contextTokenBudget: novelConfig.contextTokenBudget,
         stage: outlineBudgetStage,
+        maxOutputTokens: getEffectiveMaxOutputTokens(effectiveLlmConfig),
+        thinkingFloorTokens: thinkingMinMaxTokens(effectiveLlmConfig.reasoning ?? { mode: "auto" }),
       });
 
       try {
@@ -1999,6 +2005,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
                 maxContextSize: effectiveLlmConfig.maxContextSize,
                 contextTokenBudget: novelConfig.contextTokenBudget,
                 stage: budgetStage,
+                maxOutputTokens: getEffectiveMaxOutputTokens(effectiveLlmConfig),
+                thinkingFloorTokens: thinkingMinMaxTokens(
+                  effectiveLlmConfig.reasoning ?? { mode: "auto" },
+                ),
               });
           return {
             agentConfig: {
@@ -2877,6 +2887,8 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         maxContextSize: effectiveLlmConfig.maxContextSize,
         contextTokenBudget: novelConfig.contextTokenBudget,
         stage: "generation",
+        maxOutputTokens: getEffectiveMaxOutputTokens(effectiveLlmConfig),
+        thinkingFloorTokens: thinkingMinMaxTokens(effectiveLlmConfig.reasoning ?? { mode: "auto" }),
       });
 
       const capturedConvId = activeConversationId;
@@ -3280,6 +3292,10 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
           maxContextSize: effectiveLlmConfig.maxContextSize,
           contextTokenBudget: novelConfig.contextTokenBudget,
           stage: "generation",
+          maxOutputTokens: getEffectiveMaxOutputTokens(effectiveLlmConfig),
+          thinkingFloorTokens: thinkingMinMaxTokens(
+            effectiveLlmConfig.reasoning ?? { mode: "auto" },
+          ),
         });
         try {
           const contextHub = getContextHub(normalizePath(project.path));

+ 7 - 1
src/i18n/en.json

@@ -761,7 +761,7 @@
         "title": "Large Language / LLM Model",
         "description": "Each provider has an independent configuration. Turning one on makes it the active provider and turns the others off. Edits save immediately, and API keys remain separate per provider.",
         "longWritingContextTitle": "Chapter and outline writing requires at least a 200K context window",
-        "longWritingContextHint": "The exact minimum is 204800 characters (about 51200 tokens). Every built-in provider now defaults to at least this value, and built-in model lists exclude models whose real window is too small. Verify custom or manually entered models against the server's actual limit.",
+        "longWritingContextHint": "The minimum is 204800 tokens (about 200K). Every built-in provider defaults to at least this value. For custom or manually entered models, confirm that the server actually supports that window.",
         "expand": "Expand configuration",
         "collapse": "Collapse",
         "toggleOff": "Disable this provider",
@@ -773,6 +773,12 @@
         "apiKey": "API Key",
         "model": "Model",
         "contextWindow": "Context Window",
+        "contextWindowValue": "{{value}} tokens",
+        "contextWindowHint": "The context length from the model's spec sheet, in tokens. Minimum 200K — older configs below that are raised automatically on startup.",
+        "maxOutputTokens": "Output Limit",
+        "maxOutputTokensValue": "{{value}} tokens",
+        "maxOutputTokensHint": "How many tokens this model can emit in one reply, used to avoid rejected requests. It declares a capability rather than a request size: each task sends the smaller of what it needs and this limit, so raising it does not make replies longer.",
+        "maxOutputTokensExceedsWindow": "Exceeds the context window; it will be reduced to what the window can hold",
         "wireOpenAi": "OpenAI Compatible",
         "wireResponses": "Responses API",
         "wireAnthropic": "Anthropic Compatible",

+ 7 - 1
src/i18n/zh.json

@@ -468,7 +468,7 @@
         "title": "大语言/LLM模型",
         "description": "配置不同大语言模型服务商的 API Key、模型和连接方式。自定义模型保留在最上方,内置预设模型在下方可单独启用。",
         "longWritingContextTitle": "正文和大纲写作要求至少 200K 上下文",
-        "longWritingContextHint": "最低精确值为 204800 字符(约 51200 tokens)。所有内置供应商默认值均不低于该值,内置模型列表已移除真实窗口不足的模型。自定义或手动输入的模型请确认服务端真实支持该窗口。",
+        "longWritingContextHint": "最低值为 204800 tokens(约 200K)。所有内置供应商默认值均不低于该值。自定义或手动输入的模型请确认服务端真实支持该窗口。",
         "expand": "展开配置",
         "collapse": "收起配置",
         "toggleOff": "停用此模型",
@@ -480,6 +480,12 @@
         "apiKey": "API 密钥",
         "model": "模型",
         "contextWindow": "上下文窗口",
+        "contextWindowValue": "{{value}} tokens",
+        "contextWindowHint": "模型规格表上的上下文长度,单位为 token。最低 200K,低于该值的旧配置会在启动时自动提升。",
+        "maxOutputTokens": "输出上限",
+        "maxOutputTokensValue": "{{value}} tokens",
+        "maxOutputTokensHint": "该模型单次回复最多能输出多少 token,用于避免请求被供应商拒绝。这是能力声明而非请求长度:实际发出的取「本次任务所需」与该上限的较小值,调高不会让回复变长。",
+        "maxOutputTokensExceedsWindow": "超出上下文窗口,实际会被压回窗口可容纳的范围",
         "wireOpenAi": "OpenAI 兼容",
         "wireResponses": "Responses API",
         "wireAnthropic": "Anthropic 兼容",

+ 16 - 5
src/lib/agent/runner.ts

@@ -168,12 +168,23 @@ export class AgentRunner {
         return record
       }
       const streamRound = async () => {
+        // maxContextSize is already a token count; the remaining quarter of the
+        // window covers the response and prompt scaffolding.
         const effectiveContext = getEffectiveMaxContextSize(config.llmConfig)
-        const internalBudget = Math.max(1, Math.floor((effectiveContext / 4) * 0.75))
-        const compacted = trimChatMessagesToTokenBudget(
-          workingMessages as ChatMessage[],
-          internalBudget,
-        ) as AgentMessage[]
+        const internalBudget = Math.max(1, Math.floor(effectiveContext * 0.75))
+        let compacted: AgentMessage[]
+        try {
+          compacted = trimChatMessagesToTokenBudget(
+            workingMessages as ChatMessage[],
+            internalBudget,
+          ) as AgentMessage[]
+        } catch {
+          // streamChat retries with a 512-token output floor before giving up;
+          // surface a readable reason instead of the bare budget error.
+          throw new Error(
+            "模型上下文不足:当前对话即使压缩后仍放不下系统提示与最新请求。请缩短输入,或在设置中调高该模型的上下文窗口。",
+          )
+        }
         workingMessages.splice(0, workingMessages.length, ...compacted)
         await streamChat(
           config.llmConfig,

+ 2 - 2
src/lib/agent/tools/index.ts

@@ -37,7 +37,7 @@ export interface VirtualToolContext {
   contextPack?: ContextPack
   /** ContextPack token budget; resolved from model window when omitted. */
   tokenBudget?: number
-  /** Session model context window in characters. */
+  /** Session model context window in tokens. */
   maxContextSize?: number
 }
 
@@ -52,7 +52,7 @@ export interface ToolFactoryOptions {
   mcpTools?: Tool[]
   draftMode?: boolean
   projectPath?: string
-  /** Session model context window in characters (for trim_context defaults). */
+  /** Session model context window in tokens (for trim_context defaults). */
   maxContextSize?: number
   sourceConversationId?: string
   sourceMessageId?: string

+ 29 - 0
src/lib/chat-request-budget.test.ts

@@ -169,4 +169,33 @@ describe("trimChatMessagesToTokenBudget", () => {
       { role: "user", content: "生成第一卷完整大纲" },
     ], 5)).toThrow(LlmContextBudgetError)
   })
+
+  it("compresses the same input whether or not a mid-conversation system exists", () => {
+    // AgentRunner pushes a required-tool system message into the middle of the
+    // history. It is droppable history, so validating the survivors against the
+    // original system list by position misaligned and rejected a trim that had
+    // actually succeeded.
+    const leading: ChatMessage = { role: "system", content: `系统约束:${"规则".repeat(500)}` }
+    const history: ChatMessage[] = [
+      { role: "user", content: "早前请求".repeat(200) },
+      { role: "assistant", content: "早前回复".repeat(200) },
+    ]
+    const current: ChatMessage = {
+      role: "user",
+      content: `任务目标:${"正文".repeat(1_000)}结尾限制:保持人物关系。`,
+    }
+    const withoutMidSystem = trimChatMessagesToTokenBudget([leading, ...history, current], 500)
+    const withMidSystem = trimChatMessagesToTokenBudget([
+      leading,
+      ...history,
+      { role: "system", content: "本轮必须调用 read_chapter。" },
+      current,
+    ], 500)
+
+    expect(estimateChatMessagesTokens(withMidSystem)).toBeLessThanOrEqual(500)
+    expect(String(withMidSystem[0]?.content).trim()).not.toBe("")
+    expect(String(withMidSystem.at(-1)?.content)).toContain("任务目标")
+    expect(withMidSystem.map((message) => message.role))
+      .toEqual(withoutMidSystem.map((message) => message.role))
+  })
 })

+ 14 - 3
src/lib/chat-request-budget.ts

@@ -316,6 +316,18 @@ export function trimChatMessagesToTokenBudget(
   }
   if (estimateChatMessagesTokens(next) <= maxTokens) return next
 
+  // Systems that survived group-dropping. Mid-conversation system messages
+  // (e.g. the required-tool nudge pushed by AgentRunner) are ordinary history
+  // and may legitimately be gone by now; only what is still here has to stay
+  // non-empty through compression. Comparing against the original list by
+  // position instead would misalign the moment any system is dropped, and
+  // report a budget failure for a trim that actually succeeded.
+  // Compression below replaces entries in place, so these indices stay valid.
+  const protectedSystemIndices = next.reduce<number[]>((indices, message, index) => {
+    if (message.role === "system" && hasNonEmptyContent(message)) indices.push(index)
+    return indices
+  }, [])
+
   let latestUserIndex = -1
   for (let index = next.length - 1; index >= 0; index -= 1) {
     if (next[index]?.role === "user") {
@@ -366,9 +378,8 @@ export function trimChatMessagesToTokenBudget(
     )
   }
 
-  const protectedSystemsValid = messages
-    .filter((message) => message.role === "system" && hasNonEmptyContent(message))
-    .every((_message, index) => hasNonEmptyContent(next.filter((entry) => entry.role === "system")[index]))
+  const protectedSystemsValid = protectedSystemIndices
+    .every((index) => hasNonEmptyContent(next[index]))
   const latestUserValid = latestUserIndex < 0 || hasNonEmptyContent(next[latestUserIndex])
   if (
     estimateChatMessagesTokens(next) > maxTokens

+ 2 - 7
src/lib/context-budget.contract.spec.ts

@@ -14,23 +14,20 @@ describe("context pack budget contracts", () => {
       const general = resolveContextPackTokenBudget({
         maxContextSize,
         contextTokenBudget: 0,
-        langScale: 1,
       })
       const writing = computeWritingContextPackTokenBudget({
         maxContextSize,
         contextTokenBudget: 0,
         chapterTargetChars: 3_000,
-        langScale: 1,
       })
       expect(Number.isFinite(general)).toBe(true)
       expect(Number.isFinite(writing)).toBe(true)
       expect(general).toBeGreaterThan(0)
       expect(writing).toBeGreaterThan(0)
-      expect(general).toBeLessThanOrEqual(computeNovelContextTokenBudget(maxContextSize, 0, 1))
+      expect(general).toBeLessThanOrEqual(computeNovelContextTokenBudget(maxContextSize, 0))
       const normalizedGeneral = resolveContextPackTokenBudget({
         maxContextSize: Math.max(204_800, maxContextSize),
         contextTokenBudget: 0,
-        langScale: 1,
       })
       expect(writing).toBeLessThanOrEqual(normalizedGeneral)
     }
@@ -40,9 +37,8 @@ describe("context pack budget contracts", () => {
     const writing = computeWritingContextPackTokenBudget({
       maxContextSize: 32_000,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })
-    expect(writing).toBe(33_280)
+    expect(writing).toBe(133_120)
   })
 
   it("writing pack leaves room for output-token reserve plus scaffold", () => {
@@ -51,7 +47,6 @@ describe("context pack budget contracts", () => {
         const plan = planChapterRequestBudget({
           maxContextSize,
           chapterTargetChars,
-          langScale: 1,
           stage: "generation",
         })
         expect(

+ 3 - 3
src/lib/context-budget.spec.ts

@@ -19,9 +19,9 @@ describe("computeOutlineIngestBodyBudget", () => {
     expect(small).toBeGreaterThan(0)
   })
 
-  it("applies CJK language scale", () => {
-    const english = computeOutlineIngestBodyBudget(128_000, promptOverhead, 1)
-    const cjk = computeOutlineIngestBodyBudget(128_000, promptOverhead, 0.425)
+  it("gives CJK fewer characters because each token holds less text", () => {
+    const english = computeOutlineIngestBodyBudget(128_000, promptOverhead, 4)
+    const cjk = computeOutlineIngestBodyBudget(128_000, promptOverhead, 1)
     expect(cjk).toBeLessThan(english)
   })
 })

+ 139 - 98
src/lib/context-budget.test.ts

@@ -1,31 +1,29 @@
 import { describe, it, expect } from "vitest"
 import {
+  charsPerTokenForLanguage,
   computeContextBudget,
   computeNovelContextTokenBudget,
   computeWritingContextPackTokenBudget,
-  contextScaleForLanguage,
   resolveContextPackTokenBudget,
   planChapterRequestBudget,
   planLlmRequestBudget,
   planOutlineRequestBudget,
   LlmContextBudgetError,
-  WRITING_OUTPUT_RESERVE_MULTIPLIER,
 } from "./context-budget"
 
-// The base-math tests pin langScale=1 so they stay deterministic
+// The base-math tests pin charsPerToken explicitly so they stay deterministic
 // regardless of the active UI language (the app defaults to zh).
 describe("computeContextBudget", () => {
-  it("falls back to the 200K-char default for falsy input", () => {
+  it("falls back to the 200K-token default for falsy input", () => {
     expect(computeContextBudget(undefined, 1).maxCtx).toBe(204_800)
     expect(computeContextBudget(0, 1).maxCtx).toBe(204_800)
     expect(computeContextBudget(Number.NaN, 1).maxCtx).toBe(204_800)
   })
 
-  it("allocates fractional sub-budgets from the window", () => {
-    const b = computeContextBudget(200_000, 1)
-    expect(b.responseReserve).toBe(30_000)
-    expect(b.indexBudget).toBe(10_000)
-    expect(b.pageBudget).toBe(100_000)
+  it("converts the token window into a character capacity", () => {
+    const b = computeContextBudget(200_000, 4)
+    expect(b.maxCtx).toBe(800_000)
+    expect(b.responseReserve).toBe(120_000)
   })
 })
 
@@ -39,63 +37,107 @@ describe("shared LLM request budget", () => {
       minimumContextTokens: 4_000,
     })
     expect(plan).toMatchObject({
-      windowTokens: 51_200,
+      windowTokens: 184_320,
       outputTokens: 16_384,
-      contextTokenBudget: 26_624,
+      contextTokenBudget: 40_000,
       scaffoldReserveTokens: 8_192,
-      inputTokenBudget: 34_816,
+      inputTokenBudget: 167_936,
     })
     expect(
       plan.outputTokens + plan.contextTokenBudget + plan.scaffoldReserveTokens,
     ).toBeLessThanOrEqual(plan.windowTokens)
   })
 
+  it("treats maxContextSize as tokens, not characters", () => {
+    // The window is a token count already; planning must not divide it down.
+    // Before this was fixed a 200K window planned against 51200 tokens, so a
+    // Chinese session could only use a quarter of the model's real capacity.
+    const plan = planLlmRequestBudget({
+      maxContextSize: 204_800,
+      desiredOutputTokens: 8_192,
+      scaffoldReserveTokens: 0,
+    })
+    expect(plan.windowTokens).toBeGreaterThan(180_000)
+    expect(plan.inputTokenBudget).toBeGreaterThan(170_000)
+  })
+
   it("reduces output but never below 512 before rejecting an impossible window", () => {
     const reduced = planLlmRequestBudget({
-      maxContextSize: 4_096,
+      maxContextSize: 1_024,
       desiredOutputTokens: 8_192,
       scaffoldReserveTokens: 256,
       minimumContextTokens: 400,
     })
     expect(reduced.outputTokens).toBe(512)
-    expect(reduced.contextTokenBudget).toBe(256)
+    expect(reduced.contextTokenBudget).toBe(153)
     expect(() => planLlmRequestBudget({
-      maxContextSize: 2_000,
+      maxContextSize: 512,
       desiredOutputTokens: 8_192,
       scaffoldReserveTokens: 64,
     })).toThrow(LlmContextBudgetError)
   })
+
+  it("converges output down to the declared output cap", () => {
+    const plan = planLlmRequestBudget({
+      maxContextSize: 1_000_000,
+      desiredOutputTokens: 150_000,
+      scaffoldReserveTokens: 8_192,
+      maxOutputTokensCap: 65_536,
+    })
+    expect(plan.outputTokens).toBe(65_536)
+  })
+
+  it("raises output to the thinking floor without passing the cap", () => {
+    const raised = planLlmRequestBudget({
+      maxContextSize: 204_800,
+      desiredOutputTokens: 4_000,
+      scaffoldReserveTokens: 8_192,
+      thinkingFloorTokens: 16_384,
+    })
+    expect(raised.outputTokens).toBe(16_384)
+
+    const capped = planLlmRequestBudget({
+      maxContextSize: 204_800,
+      desiredOutputTokens: 4_000,
+      scaffoldReserveTokens: 8_192,
+      thinkingFloorTokens: 16_384,
+      maxOutputTokensCap: 8_192,
+    })
+    expect(capped.outputTokens).toBe(8_192)
+  })
 })
 
 describe("outline request budget", () => {
-  it.each([
-    [204_800, 8_192, 16_384],
-    [262_143, 8_192, 16_384],
-    [262_144, 8_192, 24_576],
-    [524_287, 8_192, 24_576],
-    [524_288, 8_192, 32_768],
-    [1_000_000, 8_192, 32_768],
-  ])("uses stage tiers at window %i", (maxContextSize, analysis, generation) => {
+  it("scales the output with the window instead of stepping through tiers", () => {
     expect(planOutlineRequestBudget({
-      maxContextSize,
+      maxContextSize: 204_800,
       stage: "analysis",
-      langScale: 1,
-    }).outputTokens).toBe(analysis)
+    }).outputTokens).toBe(8_192)
+    expect(planOutlineRequestBudget({
+      maxContextSize: 204_800,
+      stage: "generation",
+    }).outputTokens).toBe(30_720)
+    expect(planOutlineRequestBudget({
+      maxContextSize: 1_000_000,
+      stage: "generation",
+    }).outputTokens).toBe(150_000)
+  })
+
+  it("bounds the generation output by the declared output cap", () => {
     expect(planOutlineRequestBudget({
-      maxContextSize,
+      maxContextSize: 1_000_000,
       stage: "generation",
-      langScale: 1,
-    }).outputTokens).toBe(generation)
+      maxOutputTokens: 65_536,
+    }).outputTokens).toBe(65_536)
   })
 
   it("silently raises a legacy 128K window to 204800", () => {
     const plan = planOutlineRequestBudget({
       maxContextSize: 128_000,
       stage: "generation",
-      langScale: 1,
     })
-    expect(plan.windowTokens).toBe(51_200)
-    expect(plan.outputTokens).toBe(16_384)
+    expect(plan.windowTokens).toBe(184_320)
+    expect(plan.outputTokens).toBe(30_720)
   })
 })
 
@@ -109,7 +151,6 @@ describe("chapter request budget", () => {
       maxContextSize: 204_800,
       chapterTargetChars,
       stage: "generation",
-      langScale: 1,
     })
     expect(plan.outputTokens).toBe(outputTokens)
     expect(
@@ -117,85 +158,95 @@ describe("chapter request budget", () => {
     ).toBeLessThanOrEqual(plan.windowTokens)
   })
 
+  it("keeps chapter output tied to target length, not to the window", () => {
+    // A 3000-character chapter needs the same output on a 1M model as on a 200K
+    // one, so the generous window must not inflate the request.
+    expect(planChapterRequestBudget({
+      maxContextSize: 1_000_000,
+      chapterTargetChars: 3_000,
+      stage: "generation",
+    }).outputTokens).toBe(8_000)
+  })
+
+  it("bounds the generation output by the declared output cap", () => {
+    expect(planChapterRequestBudget({
+      maxContextSize: 204_800,
+      chapterTargetChars: 6_000,
+      stage: "generation",
+      maxOutputTokens: 4_096,
+    }).outputTokens).toBe(4_096)
+  })
+
   it("uses 4096 tokens for task analysis", () => {
     expect(planChapterRequestBudget({
       maxContextSize: 204_800,
       chapterTargetChars: 3_000,
       stage: "analysis",
-      langScale: 1,
     }).outputTokens).toBe(4_096)
   })
 })
 
-describe("contextScaleForLanguage", () => {
-  it("keeps scale 1 for English and other non-CJK languages", () => {
-    expect(contextScaleForLanguage("en")).toBe(1)
-    expect(contextScaleForLanguage("en-US")).toBe(1)
-    expect(contextScaleForLanguage("fr")).toBe(1)
+describe("charsPerTokenForLanguage", () => {
+  it("uses the 4:1 ratio for English and other non-CJK languages", () => {
+    expect(charsPerTokenForLanguage("en")).toBe(4)
+    expect(charsPerTokenForLanguage("en-US")).toBe(4)
+    expect(charsPerTokenForLanguage("fr")).toBe(4)
   })
 
   it("falls back to the active UI language when none is given", () => {
-    // Test env initialises i18n to zh, so the implicit lookup is CJK-scaled.
-    expect(contextScaleForLanguage()).toBeCloseTo(0.425, 5)
+    // Test env initialises i18n to zh, so the implicit lookup is CJK.
+    expect(charsPerTokenForLanguage()).toBe(1)
   })
 
-  it("shrinks the window for CJK languages", () => {
-    expect(contextScaleForLanguage("zh")).toBeCloseTo(0.425, 5)
-    expect(contextScaleForLanguage("zh-CN")).toBeCloseTo(0.425, 5)
-    expect(contextScaleForLanguage("ja")).toBeCloseTo(0.425, 5)
-    expect(contextScaleForLanguage("ko")).toBeCloseTo(0.425, 5)
+  it("counts one character per token for CJK languages", () => {
+    // Must match the token estimator, which also counts 1 CJK char = 1 token.
+    expect(charsPerTokenForLanguage("zh")).toBe(1)
+    expect(charsPerTokenForLanguage("zh-CN")).toBe(1)
+    expect(charsPerTokenForLanguage("ja")).toBe(1)
+    expect(charsPerTokenForLanguage("ko")).toBe(1)
   })
 })
 
 describe("computeContextBudget language scaling", () => {
-  it("scales the effective window down for CJK UIs", () => {
-    const zh = contextScaleForLanguage("zh")
-    expect(computeContextBudget(200_000, zh).maxCtx).toBe(85_000)
-    expect(computeContextBudget(204_800, zh).maxCtx).toBe(87_040)
+  it("yields fewer characters for CJK because each token holds less", () => {
+    const zh = charsPerTokenForLanguage("zh")
+    expect(computeContextBudget(200_000, zh).maxCtx).toBe(200_000)
+    expect(computeContextBudget(204_800, zh).maxCtx).toBe(204_800)
   })
 
-  it("leaves English windows untouched", () => {
-    expect(computeContextBudget(200_000, contextScaleForLanguage("en")).maxCtx).toBe(200_000)
+  it("yields four characters per token for English", () => {
+    expect(computeContextBudget(200_000, charsPerTokenForLanguage("en")).maxCtx).toBe(800_000)
   })
 })
 
 describe("computeNovelContextTokenBudget", () => {
-  it("preserves the legacy 32K-token deep-chapter budget on the default window", () => {
-    // Default window (204800 chars) → cap 33280 tokens, so 32000 is kept intact.
-    expect(computeNovelContextTokenBudget(204_800, 32_000, 1)).toBe(32_000)
-    expect(computeNovelContextTokenBudget(undefined, 32_000, 1)).toBe(32_000)
+  it("keeps a requested budget that fits under the window share", () => {
+    expect(computeNovelContextTokenBudget(204_800, 32_000)).toBe(32_000)
+    expect(computeNovelContextTokenBudget(undefined, 32_000)).toBe(32_000)
   })
 
   it("caps an unset (0 / unlimited) budget at the window-derived ceiling", () => {
-    expect(computeNovelContextTokenBudget(204_800, 0, 1)).toBe(33_280)
-    expect(computeNovelContextTokenBudget(204_800, undefined, 1)).toBe(33_280)
+    expect(computeNovelContextTokenBudget(204_800, 0)).toBe(133_120)
+    expect(computeNovelContextTokenBudget(204_800, undefined)).toBe(133_120)
   })
 
   it("clamps an over-large user budget down to the ceiling", () => {
-    expect(computeNovelContextTokenBudget(204_800, 100_000, 1)).toBe(33_280)
+    expect(computeNovelContextTokenBudget(204_800, 200_000)).toBe(133_120)
   })
 
   it("shrinks the budget proportionally for small windows", () => {
-    // 32000 chars → floor(32000 * 0.65 / 4) = 5200 tokens.
-    expect(computeNovelContextTokenBudget(32_000, 32_000, 1)).toBe(5_200)
+    expect(computeNovelContextTokenBudget(32_000, 32_000)).toBe(20_800)
   })
 
   it("never drops below the token floor", () => {
-    expect(computeNovelContextTokenBudget(1_000, 0, 1)).toBe(4_000)
-  })
-
-  it("tightens the ceiling for CJK UIs so the same request is capped down", () => {
-    // zh: maxCtx 204800*0.425=87040 → cap floor(87040*0.65/4)=14144 tokens.
-    const zh = contextScaleForLanguage("zh")
-    expect(computeNovelContextTokenBudget(204_800, 32_000, zh)).toBe(14_144)
-    expect(computeNovelContextTokenBudget(204_800, 0, zh)).toBe(14_144)
+    expect(computeNovelContextTokenBudget(1_000, 0)).toBe(4_000)
   })
 })
 
 describe("resolveContextPackTokenBudget", () => {
   it("always returns a positive finite budget for auto mode", () => {
-    const budget = resolveContextPackTokenBudget({ maxContextSize: 204_800, contextTokenBudget: 0, langScale: 1 })
-    expect(budget).toBe(33_280)
+    const budget = resolveContextPackTokenBudget({ maxContextSize: 204_800, contextTokenBudget: 0 })
+    expect(budget).toBe(133_120)
     expect(Number.isFinite(budget)).toBe(true)
   })
 
@@ -203,79 +254,69 @@ describe("resolveContextPackTokenBudget", () => {
     expect(resolveContextPackTokenBudget({
       maxContextSize: 204_800,
       contextTokenBudget: 10_000,
-      langScale: 1,
     })).toBe(10_000)
   })
 })
 
 describe("computeWritingContextPackTokenBudget", () => {
-  it("reserves at least maxOutputTokens (as chars) before allocating the pack", () => {
-    const maxContextSize = 204_800
-    const chapterTargetChars = 3_000
-    const langScale = 1
-    const { maxCtx } = computeContextBudget(maxContextSize, langScale)
+  it("leaves room for the chapter output and scaffolding inside the window", () => {
+    const plan = planChapterRequestBudget({
+      maxContextSize: 204_800,
+      contextTokenBudget: 0,
+      chapterTargetChars: 3_000,
+      stage: "generation",
+    })
     const budget = computeWritingContextPackTokenBudget({
-      maxContextSize,
+      maxContextSize: 204_800,
       contextTokenBudget: 0,
-      chapterTargetChars,
-      langScale,
+      chapterTargetChars: 3_000,
     })
-    const maxOutputTokens = 8_000
-    const targetReserveTokens = Math.ceil((chapterTargetChars * WRITING_OUTPUT_RESERVE_MULTIPLIER) / 1.7)
-    const outputReserveChars = Math.max(targetReserveTokens, maxOutputTokens) * 4
-    const scaffold = Math.max(8_000, Math.floor(maxCtx * 0.08))
-    expect(budget * 4 + outputReserveChars + scaffold).toBeLessThanOrEqual(maxCtx)
-    expect(outputReserveChars).toBeGreaterThanOrEqual(maxOutputTokens * 4)
+    expect(budget).toBe(plan.contextTokenBudget)
+    expect(budget + plan.outputTokens + plan.scaffoldReserveTokens)
+      .toBeLessThanOrEqual(plan.windowTokens)
   })
 
   it("shrinks when chapter target grows", () => {
     const smallTarget = computeWritingContextPackTokenBudget({
       maxContextSize: 204_800,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })
     const largeTarget = computeWritingContextPackTokenBudget({
       maxContextSize: 204_800,
       chapterTargetChars: 6_000,
-      langScale: 1,
     })
     expect(largeTarget).toBeLessThanOrEqual(smallTarget)
   })
 
   it("grows with a larger window within the general cap", () => {
     const smallWindow = computeWritingContextPackTokenBudget({
-      maxContextSize: 64_000,
+      maxContextSize: 204_800,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })
     const largeWindow = computeWritingContextPackTokenBudget({
-      maxContextSize: 204_800,
+      maxContextSize: 1_000_000,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })
-    expect(largeWindow).toBeGreaterThanOrEqual(smallWindow)
+    expect(largeWindow).toBeGreaterThan(smallWindow)
   })
 
   it("never exceeds the general window cap", () => {
     const budget = computeWritingContextPackTokenBudget({
       maxContextSize: 204_800,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })
-    expect(budget).toBeLessThanOrEqual(computeNovelContextTokenBudget(204_800, 0, 1))
+    expect(budget).toBeLessThanOrEqual(computeNovelContextTokenBudget(204_800, 0))
   })
 
   it("clamps an explicit user budget to the writing-derived auto budget", () => {
     const auto = computeWritingContextPackTokenBudget({
       maxContextSize: 204_800,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })
     expect(computeWritingContextPackTokenBudget({
       maxContextSize: 204_800,
-      contextTokenBudget: 100_000,
+      contextTokenBudget: 300_000,
       chapterTargetChars: 3_000,
-      langScale: 1,
     })).toBe(auto)
   })
 })

+ 104 - 117
src/lib/context-budget.ts

@@ -1,34 +1,21 @@
 /**
- * Pure budget allocator for chat context assembly.
+ * Pure budget allocator for LLM request assembly.
  *
- * Given an LLM's `maxContextSize` (in characters — see wiki-store.ts;
- * yes, that's a quirky unit, but tokens-vs-chars conversion lives
- * elsewhere), compute the per-section character budgets used by
- * chat-panel when packing the prompt.
+ * `maxContextSize` is the model's context window in TOKENS — it is copied
+ * straight from the provider's spec sheet (Gemini 1M, Kimi 256K, …) by the
+ * settings UI. Two domains are derived from it here:
  *
- * Why this is its own module:
- *   - The math has corner cases that deserve their own tests
- *     (tiny configs, huge configs, the legacy 30K cap removal).
- *   - Inlining it in chat-panel.tsx made it untestable in isolation.
+ *   - Token domain (`planLlmRequestBudget` and the chapter/outline planners):
+ *     works in the same unit as the window, so no conversion happens at all.
+ *     This is the authoritative allocator — it guarantees input + output fit.
+ *   - Character domain (`computeContextBudget`): converts the token window
+ *     into how many CHARACTERS of prompt text will fit, for the callers that
+ *     slice raw strings. The conversion rate is language-dependent, which is
+ *     what `charsPerTokenForLanguage` supplies.
  *
- * The shape of the budget:
- *
- *   ┌─────────────────────────────────────────────────────┐
- *   │              maxCtx (100%)                          │
- *   ├──────┬───────────────┬──────────────────┬───────────┤
- *   │ idx  │   pages       │  history + sys   │  resp     │
- *   │  5%  │    50%        │    ~30%          │   15%     │
- *   └──────┴───────────────┴──────────────────┴───────────┘
- *
- * `historyAndSystem` isn't returned because it's not enforced as a
- * single budget — system prompt is roughly fixed-size, and history
- * is gated by `maxHistoryMessages` (count, not bytes). The leftover
- * just provides headroom.
- *
- * The response reserve is a "passive" reservation: we don't pass
- * `max_tokens: responseReserve / 3` to the LLM (yet — that's a
- * follow-up). We just refuse to fill above (maxCtx - responseReserve)
- * so the LLM has room to actually answer.
+ * The two must never both apply a language factor to the same value: the
+ * token domain already speaks tokens, so scaling it by language would count
+ * the same density twice.
  */
 
 import i18n from "@/i18n"
@@ -36,43 +23,28 @@ import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
 
 /** Result of `computeContextBudget`. All values are character counts. */
 export interface ContextBudget {
-  /** The model's full context window (always populated; falls back
-   *  to a sensible default when caller passes 0/undefined). */
+  /** How many characters of prompt text the model's token window holds,
+   *  at the active language's density. Falls back to a sensible default
+   *  when the caller passes 0/undefined. */
   maxCtx: number
   /** Characters NOT to be filled with prompt content — left empty so
    *  the LLM has room to write its response. */
   responseReserve: number
-  /** Wiki index summary budget. ~5% — enough to list every page's
-   *  title without occupying serious budget. */
-  indexBudget: number
-  /** Total characters available for retrieved wiki page content. */
-  pageBudget: number
-  /** Per-page truncation cap. A single page won't be embedded longer
-   *  than this even if `pageBudget` would allow it. Scales with
-   *  pageBudget (used to be hard-capped at 30,000 chars regardless
-   *  of context size — that wasted budget on long-context models). */
-  maxPageSize: number
 }
 
 const DEFAULT_MAX_CTX = 204_800
-const RESPONSE_RESERVE_FRAC = 0.15
-const INDEX_BUDGET_FRAC = 0.05
-const PAGE_BUDGET_FRAC = 0.5
-const PER_PAGE_FRAC = 0.3
-const PER_PAGE_FLOOR = 5_000
-
-/** Approximate characters per token the whole budgeting layer assumes.
- *  `maxContextSize` is expressed in CHARACTERS under the English-ish
- *  assumption of ~4 chars/token (see contextPackToPrompt). */
+export const RESPONSE_RESERVE_FRAC = 0.15
+
+/** Characters per token for English-ish text — the conventional 4:1. */
 const CHARS_PER_TOKEN = 4
-/** Empirical chars/token for CJK (Chinese/Japanese/Korean) text. CJK is
- *  ~2.3x denser than English, so the same character budget maps to far
- *  more tokens and can overflow the model window. */
-const CHARS_PER_TOKEN_CJK = 1.7
-/** Effective-window multiplier for CJK UIs. Shrinks the character budget
- *  so its TOKEN footprint matches what the English assumption expects,
- *  keeping token usage comparable across languages. ≈ 0.425. */
-const CJK_CONTEXT_SCALE = CHARS_PER_TOKEN_CJK / CHARS_PER_TOKEN
+/** Characters per token for CJK text. Deliberately 1.0 to match
+ *  `src/lib/context-hub/token-estimator.ts`, which counts one CJK character
+ *  as one token. A looser value here would let the character budgets admit
+ *  more text than the token estimator allows, so the surplus would be packed
+ *  in and then trimmed back out in `streamChat` — wasted work and lost
+ *  content. Real tokenizers land around 1–1.5 chars/token, so 1.0 is the
+ *  safe end. */
+const CHARS_PER_TOKEN_CJK = 1
 
 function isCjkLanguage(lang: string | undefined): boolean {
   if (!lang) return false
@@ -81,59 +53,39 @@ function isCjkLanguage(lang: string | undefined): boolean {
 }
 
 /**
- * Window scale for a UI language. English (and any non-CJK language)
- * returns 1 → zero behavioural change. CJK returns `CJK_CONTEXT_SCALE`
- * so the character budgets translate to a safe token footprint.
+ * How many characters one token holds in a given UI language, used to turn
+ * the model's token window into a character budget.
  *
  * `lang` defaults to the active i18n language; pass an explicit value
  * (e.g. in tests) to keep the calculation deterministic.
  */
-export function contextScaleForLanguage(lang?: string): number {
+export function charsPerTokenForLanguage(lang?: string): number {
   const resolved =
     lang ?? (typeof i18n?.language === "string" ? i18n.language : undefined)
-  return isCjkLanguage(resolved) ? CJK_CONTEXT_SCALE : 1
+  return isCjkLanguage(resolved) ? CHARS_PER_TOKEN_CJK : CHARS_PER_TOKEN
 }
 
 /**
- * Compute character budgets from the LLM's max context window.
+ * Convert the model's token window into character budgets.
  *
- * Falsy `maxContextSize` (0 / NaN / undefined) falls back to the
- * pre-Phase-1 default of 200K chars so existing configs don't break.
+ * Falsy `maxContextSize` (0 / NaN / undefined) falls back to the default
+ * 200K-token window so existing configs don't break.
  */
 export function computeContextBudget(
   maxContextSize: number | undefined,
-  langScale: number = contextScaleForLanguage(),
+  charsPerToken: number = charsPerTokenForLanguage(),
 ): ContextBudget {
-  const rawMaxCtx =
+  const windowTokens =
     typeof maxContextSize === "number" && maxContextSize > 0
       ? maxContextSize
       : DEFAULT_MAX_CTX
-  const scale = typeof langScale === "number" && langScale > 0 ? langScale : 1
-  const maxCtx = Math.max(1, Math.floor(rawMaxCtx * scale))
-
-  const responseReserve = Math.floor(maxCtx * RESPONSE_RESERVE_FRAC)
-  const indexBudget = Math.floor(maxCtx * INDEX_BUDGET_FRAC)
-  const pageBudget = Math.floor(maxCtx * PAGE_BUDGET_FRAC)
-
-  // Per-page cap rules:
-  //   - At minimum, allow PER_PAGE_FLOOR (5K) so a small config still
-  //     fits one short page.
-  //   - At maximum, never exceed pageBudget itself — for tiny configs
-  //     where pageBudget < 5K, the floor would otherwise allow a
-  //     single page bigger than the entire page budget, which then
-  //     gets entirely rejected by tryAddPage in chat-panel.
-  //   - Otherwise scale linearly with pageBudget at PER_PAGE_FRAC (30%).
-  const maxPageSize = Math.min(
-    pageBudget,
-    Math.max(PER_PAGE_FLOOR, Math.floor(pageBudget * PER_PAGE_FRAC)),
-  )
+  const density =
+    typeof charsPerToken === "number" && charsPerToken > 0 ? charsPerToken : CHARS_PER_TOKEN
+  const maxCtx = Math.max(1, Math.floor(windowTokens * density))
 
   return {
     maxCtx,
-    responseReserve,
-    indexBudget,
-    pageBudget,
-    maxPageSize,
+    responseReserve: Math.floor(maxCtx * RESPONSE_RESERVE_FRAC),
   }
 }
 
@@ -157,18 +109,21 @@ const NOVEL_CONTEXT_TOKEN_FLOOR = 4_000
  * clamped to the window-derived cap; when unset the cap itself is used so
  * the injection is never truly unbounded.
  *
- * Unit note: `maxContextSize` is in CHARACTERS while `contextPackToPrompt`
- * expects a TOKEN budget (~4 chars/token), hence the division.
+ * Stays entirely in the token domain: the window is already tokens and the
+ * consumer wants tokens, so there is no character round-trip and no language
+ * factor. Language density is the token estimator's job.
  */
 export function computeNovelContextTokenBudget(
   maxContextSize: number | undefined,
   requestedTokenBudget?: number,
-  langScale?: number,
 ): number {
-  const { maxCtx } = computeContextBudget(maxContextSize, langScale)
+  const windowTokens =
+    typeof maxContextSize === "number" && maxContextSize > 0
+      ? maxContextSize
+      : DEFAULT_MAX_CTX
   const cap = Math.max(
     NOVEL_CONTEXT_TOKEN_FLOOR,
-    Math.floor((maxCtx * NOVEL_CONTEXT_FRAC) / CHARS_PER_TOKEN),
+    Math.floor(windowTokens * NOVEL_CONTEXT_FRAC),
   )
   if (requestedTokenBudget && requestedTokenBudget > 0) {
     return Math.min(requestedTokenBudget, cap)
@@ -180,7 +135,6 @@ export interface ResolveContextPackTokenBudgetInput {
   maxContextSize?: number
   /** User setting; 0 / undefined = auto from window. */
   contextTokenBudget?: number
-  langScale?: number
 }
 
 /**
@@ -193,7 +147,6 @@ export function resolveContextPackTokenBudget(
   return computeNovelContextTokenBudget(
     input.maxContextSize,
     input.contextTokenBudget,
-    input.langScale,
   )
 }
 
@@ -206,6 +159,16 @@ export class LlmContextBudgetError extends Error {
   }
 }
 
+/**
+ * Headroom kept between our token estimates and the model's real window.
+ * Estimation is approximate in both directions (tokenizer differences,
+ * scaffolding we don't see), so we plan against 90% of the advertised
+ * window. This replaces an earlier `/ 4`, which looked like a safety factor
+ * but was actually a character-to-token conversion applied to a value that
+ * was already in tokens — shrinking every window to a quarter of its size.
+ */
+const LLM_WINDOW_SAFETY_FRAC = 0.9
+
 export interface LlmRequestBudgetInput {
   maxContextSize?: number
   desiredOutputTokens: number
@@ -213,6 +176,14 @@ export interface LlmRequestBudgetInput {
   scaffoldReserveTokens: number
   minimumContextTokens?: number
   minimumOutputTokens?: number
+  /** The model's declared maximum output, from the user's settings. Output
+   *  is never planned above this even when the window could hold more. */
+  maxOutputTokensCap?: number
+  /** Output the active reasoning level needs before it can produce any final
+   *  content (`thinkingMinMaxTokens`). Raises the plan, but stays subject to
+   *  the cap and the window — unlike a floor applied to the request body,
+   *  which would silently break the conservation guaranteed here. */
+  thinkingFloorTokens?: number
 }
 
 export interface LlmRequestBudgetPlan {
@@ -234,26 +205,31 @@ export function planLlmRequestBudget(input: LlmRequestBudgetInput): LlmRequestBu
   const rawWindow = Number.isFinite(input.maxContextSize) && (input.maxContextSize as number) > 0
     ? Math.floor(input.maxContextSize as number)
     : normalizeUserLlmContextSize(undefined)
-  const windowTokens = Math.floor(rawWindow / CHARS_PER_TOKEN)
+  const windowTokens = Math.max(1, Math.floor(rawWindow * LLM_WINDOW_SAFETY_FRAC))
   const scaffoldReserveTokens = finiteNonNegative(input.scaffoldReserveTokens)
   const minimumOutputTokens = Math.max(
     MIN_LLM_OUTPUT_TOKENS,
     finiteNonNegative(input.minimumOutputTokens, MIN_LLM_OUTPUT_TOKENS),
   )
+  const outputCap = finiteNonNegative(input.maxOutputTokensCap, Number.MAX_SAFE_INTEGER)
   const desiredOutputTokens = Math.max(
     minimumOutputTokens,
     finiteNonNegative(input.desiredOutputTokens, minimumOutputTokens),
   )
+  // The thinking floor may not push output past what the model can emit.
+  const thinkingFloorTokens = Math.min(finiteNonNegative(input.thinkingFloorTokens), outputCap)
+  const targetOutputTokens = Math.max(desiredOutputTokens, thinkingFloorTokens)
   const minimumContextTokens = finiteNonNegative(input.minimumContextTokens)
   const available = windowTokens - scaffoldReserveTokens
   if (available < minimumOutputTokens) throw new LlmContextBudgetError()
 
   // Keep the requested minimum context where possible, then allocate output.
   // If both cannot fit, context is the degradable side; output never drops below 512.
-  const outputTokens = Math.min(
-    desiredOutputTokens,
-    Math.max(minimumOutputTokens, available - minimumContextTokens),
+  const outputCeiling = Math.max(
+    minimumOutputTokens,
+    Math.min(outputCap, available - minimumContextTokens),
   )
+  const outputTokens = Math.min(targetOutputTokens, outputCeiling)
   const remainingForContext = Math.max(0, available - outputTokens)
   const requestedContextTokens = finiteNonNegative(input.requestedContextTokens)
   const contextTokenBudget = requestedContextTokens > 0
@@ -277,7 +253,8 @@ export interface PlanChapterRequestBudgetInput {
   contextTokenBudget?: number
   chapterTargetChars?: number
   stage: ChapterBudgetStage
-  langScale?: number
+  maxOutputTokens?: number
+  thinkingFloorTokens?: number
 }
 
 function chapterMaxOutputTokens(targetChars?: number): number {
@@ -294,8 +271,10 @@ export function planChapterRequestBudget(
   const genericContextCap = computeNovelContextTokenBudget(
     normalizedWindow,
     input.contextTokenBudget,
-    input.langScale,
   )
+  // Chapter output is sized from the user's target chapter length rather than
+  // a share of the window: a 3000-character chapter needs the same output on a
+  // 200K model as on a 1M one.
   return planLlmRequestBudget({
     maxContextSize: normalizedWindow,
     desiredOutputTokens: input.stage === "analysis"
@@ -304,33 +283,39 @@ export function planChapterRequestBudget(
     requestedContextTokens: genericContextCap,
     scaffoldReserveTokens: 8_000,
     minimumContextTokens: 2_000,
+    maxOutputTokensCap: input.maxOutputTokens,
+    thinkingFloorTokens: input.thinkingFloorTokens,
   })
 }
 
 export type OutlineBudgetStage = "analysis" | "generation"
 
+/** Share of the window the outline's own response may claim. Reuses the
+ *  response reserve the rest of the budgeting layer already assumes. */
+const OUTLINE_GENERATION_OUTPUT_FRAC = RESPONSE_RESERVE_FRAC
+/** Analysis passes summarise rather than draft, so they need far less. */
+const OUTLINE_ANALYSIS_OUTPUT_FRAC = 0.04
+
 export interface PlanOutlineRequestBudgetInput {
   maxContextSize?: number
   contextTokenBudget?: number
   stage: OutlineBudgetStage
-  langScale?: number
+  maxOutputTokens?: number
+  thinkingFloorTokens?: number
 }
 
 export function planOutlineRequestBudget(
   input: PlanOutlineRequestBudgetInput,
 ): LlmRequestBudgetPlan {
   const normalizedWindow = normalizeUserLlmContextSize(input.maxContextSize)
-  const desiredOutputTokens = input.stage === "analysis"
-    ? 8_192
-    : normalizedWindow < 262_144
-      ? 16_384
-      : normalizedWindow < 524_288
-        ? 24_576
-        : 32_768
+  // Scales with the window instead of stepping through fixed tiers, and is
+  // then bounded by the user's declared output cap inside the kernel.
+  const desiredOutputTokens = Math.floor(normalizedWindow * (input.stage === "analysis"
+    ? OUTLINE_ANALYSIS_OUTPUT_FRAC
+    : OUTLINE_GENERATION_OUTPUT_FRAC))
   const genericContextCap = computeNovelContextTokenBudget(
     normalizedWindow,
     input.contextTokenBudget,
-    input.langScale,
   )
   return planLlmRequestBudget({
     maxContextSize: normalizedWindow,
@@ -338,6 +323,8 @@ export function planOutlineRequestBudget(
     requestedContextTokens: genericContextCap,
     scaffoldReserveTokens: 8_192,
     minimumContextTokens: 4_000,
+    maxOutputTokensCap: input.maxOutputTokens,
+    thinkingFloorTokens: input.thinkingFloorTokens,
   })
 }
 
@@ -348,7 +335,7 @@ export interface ComputeWritingContextPackTokenBudgetInput {
   maxContextSize?: number
   contextTokenBudget?: number
   chapterTargetChars?: number
-  langScale?: number
+  maxOutputTokens?: number
 }
 
 /**
@@ -362,7 +349,7 @@ export function computeWritingContextPackTokenBudget(
     contextTokenBudget: input.contextTokenBudget,
     chapterTargetChars: input.chapterTargetChars,
     stage: "generation",
-    langScale: input.langScale,
+    maxOutputTokens: input.maxOutputTokens,
   }).contextTokenBudget
 }
 
@@ -379,15 +366,15 @@ function clampBudget(value: number, min: number, max: number): number {
  * Character budget for the outline body in `ingestOutline`.
  *
  * Reserves space for fixed prompts and JSON output, then allocates the
- * remainder to the outline markdown. Scales with `maxContextSize` and
- * CJK language scale like other budget helpers.
+ * remainder to the outline markdown. Scales with `maxContextSize` and the
+ * active language's character density like other character-domain helpers.
  */
 export function computeOutlineIngestBodyBudget(
   maxContextSize: number | undefined,
   promptOverheadChars: number,
-  langScale?: number,
+  charsPerToken?: number,
 ): number {
-  const { maxCtx, responseReserve } = computeContextBudget(maxContextSize, langScale)
+  const { maxCtx, responseReserve } = computeContextBudget(maxContextSize, charsPerToken)
   const outputReserve = Math.max(responseReserve, Math.floor(maxCtx * 0.15))
   const instructionReserve = Math.max(promptOverheadChars, Math.floor(maxCtx * 0.08))
   const available = maxCtx - outputReserve - instructionReserve

+ 1 - 1
src/lib/context-hub/composer.ts

@@ -11,7 +11,7 @@ export interface ComposeContextInput {
   confidence?: number
   /** Explicit token budget; 0 / undefined = window-derived safe cap. */
   tokenBudget?: number
-  /** Model context window in characters (wiki-store `maxContextSize`). */
+  /** Model context window in tokens (wiki-store `maxContextSize`). */
   maxContextSize?: number
 }
 export interface ComposedContext {

+ 1 - 1
src/lib/context-hub/types.ts

@@ -141,7 +141,7 @@ export interface ContextHubRequest {
   existingSummary?: SessionContextSummary
   /** Explicit token budget; 0 / undefined = window-derived safe cap. */
   tokenBudget?: number
-  /** Model context window in characters (wiki-store `maxContextSize`). */
+  /** Model context window in tokens (wiki-store `maxContextSize`). */
   maxContextSize?: number
   forceRefresh?: boolean
 }

+ 12 - 1
src/lib/env-llm-defaults.ts

@@ -1,5 +1,8 @@
 import type { LlmConfig, ProviderConfigs } from "@/stores/wiki-store"
-import { normalizeUserLlmContextSize } from "@/lib/llm-context-size"
+import {
+  normalizeUserLlmContextSize,
+  normalizeUserLlmMaxOutputTokens,
+} from "@/lib/llm-context-size"
 
 const trimEnv = (value: unknown): string => {
   return typeof value === "string" ? value.trim() : ""
@@ -10,6 +13,11 @@ const readContextSize = (): number => {
   return normalizeUserLlmContextSize(raw)
 }
 
+const readMaxOutputTokens = (): number => {
+  const raw = Number(trimEnv(import.meta.env.VITE_QMAI_LLM_MAX_OUTPUT_TOKENS))
+  return normalizeUserLlmMaxOutputTokens(raw)
+}
+
 export function loadEnvLlmDefault(): {
   config: LlmConfig
   providerConfigs: ProviderConfigs
@@ -22,6 +30,7 @@ export function loadEnvLlmDefault(): {
   if (!apiKey || !customEndpoint || !model) return null
 
   const maxContextSize = readContextSize()
+  const maxOutputTokens = readMaxOutputTokens()
   const config: LlmConfig = {
     provider: "custom",
     apiKey,
@@ -29,6 +38,7 @@ export function loadEnvLlmDefault(): {
     ollamaUrl: "http://localhost:11434",
     customEndpoint,
     maxContextSize,
+    maxOutputTokens,
     apiMode: "chat_completions",
     reasoning: { mode: "auto" },
   }
@@ -42,6 +52,7 @@ export function loadEnvLlmDefault(): {
         baseUrl: customEndpoint,
         apiMode: "chat_completions",
         maxContextSize,
+        maxOutputTokens,
         reasoning: { mode: "auto" },
       },
     },

+ 22 - 21
src/lib/ingest.prompt.test.ts

@@ -8,28 +8,29 @@ import {
   splitSourceIntoSemanticChunks,
 } from "./ingest"
 
-// langScale=1 pins these ladder-math tests to the English window so they
-// stay deterministic regardless of the active UI language (default zh).
+// The character-domain helpers take chars/token explicitly (4 = English-ish,
+// 1 = CJK) so they stay deterministic regardless of the active UI language.
 describe("long-source ingest planning", () => {
   it("scales generation output tokens with the configured context window", () => {
-    expect(computeIngestGenerationMaxTokens(64_000, 1)).toBe(8_192)
-    expect(computeIngestGenerationMaxTokens(128_000, 1)).toBe(16_384)
-    expect(computeIngestGenerationMaxTokens(256_000, 1)).toBe(24_576)
-    expect(computeIngestGenerationMaxTokens(1_000_000, 1)).toBe(32_768)
-    expect(computeIngestReviewMaxTokens(1_000_000, 1)).toBe(8_192)
+    expect(computeIngestGenerationMaxTokens(64_000)).toBe(8_192)
+    expect(computeIngestGenerationMaxTokens(128_000)).toBe(16_384)
+    expect(computeIngestGenerationMaxTokens(256_000)).toBe(24_576)
+    expect(computeIngestGenerationMaxTokens(1_000_000)).toBe(32_768)
+    expect(computeIngestReviewMaxTokens(1_000_000)).toBe(8_192)
   })
 
-  it("drops to a lower output tier under CJK scaling for the same window", () => {
-    // 128000 chars * 0.425 ≈ 54400 → below the 128K tier → default 8192.
-    expect(computeIngestGenerationMaxTokens(128_000, 0.425)).toBe(8_192)
+  it("picks the output tier from the token window alone", () => {
+    // A model's output ceiling is a property of the model, not of the UI
+    // language, so the tier no longer moves with character density.
+    expect(computeIngestGenerationMaxTokens(128_000)).toBe(16_384)
   })
 
   it("scales analysis output tokens with the window but caps at 8192 (floor 4096)", () => {
     // Small window keeps the legacy 4096 floor.
-    expect(computeIngestAnalysisMaxTokens(64_000, 1)).toBe(4_096)
+    expect(computeIngestAnalysisMaxTokens(64_000)).toBe(4_096)
     // Larger windows scale up but never exceed the 8192 cap.
-    expect(computeIngestAnalysisMaxTokens(128_000, 1)).toBe(8_192)
-    expect(computeIngestAnalysisMaxTokens(1_000_000, 1)).toBe(8_192)
+    expect(computeIngestAnalysisMaxTokens(128_000)).toBe(8_192)
+    expect(computeIngestAnalysisMaxTokens(1_000_000)).toBe(8_192)
   })
 
   it("scales source budget from the configured context window instead of a fixed 50k cap", () => {
@@ -41,9 +42,9 @@ describe("long-source ingest planning", () => {
     expect(large).toBeLessThanOrEqual(300_000)
   })
 
-  it("shrinks the source budget under CJK scaling", () => {
-    const en = computeIngestSourceBudget(1_000_000, 8_000, 1)
-    const zh = computeIngestSourceBudget(1_000_000, 8_000, 0.425)
+  it("gives CJK fewer characters than English for the same token window", () => {
+    const en = computeIngestSourceBudget(200_000, 8_000, 4)
+    const zh = computeIngestSourceBudget(200_000, 8_000, 1)
     expect(zh).toBeLessThan(en)
   })
 
@@ -52,9 +53,9 @@ describe("long-source ingest planning", () => {
   })
 
   it("shrinks output tokens so prompt + output fits the window", () => {
-    // 64000-char window → 16000 tokens; 60000-char prompt → 15000 tokens in;
-    // only 1000 tokens left for output.
-    expect(fitIngestOutputToWindow(64_000, 60_000, 8_192, 1)).toBe(1_000)
+    // 64000-token window; a 60000-character CJK prompt is 60000 tokens in,
+    // leaving 4000 for output.
+    expect(fitIngestOutputToWindow(64_000, 60_000, 8_192, 1)).toBe(4_000)
   })
 
   it("falls back to the output floor when the prompt already overflows", () => {
@@ -62,8 +63,8 @@ describe("long-source ingest planning", () => {
   })
 
   it("leaves less output room for CJK prompts than English ones", () => {
-    const en = fitIngestOutputToWindow(64_000, 40_000, 8_192, 1)
-    const zh = fitIngestOutputToWindow(64_000, 40_000, 8_192, 0.425)
+    const en = fitIngestOutputToWindow(64_000, 250_000, 8_192, 4)
+    const zh = fitIngestOutputToWindow(64_000, 250_000, 8_192, 1)
     expect(zh).toBeLessThan(en)
   })
 

+ 25 - 18
src/lib/ingest.ts

@@ -1220,9 +1220,9 @@ function clampNumber(value: number, min: number, max: number): number {
 export function computeIngestSourceBudget(
   maxContextSize: number | undefined,
   stableContextLength: number,
-  langScale?: number,
+  charsPerToken?: number,
 ): number {
-  const { maxCtx, responseReserve } = computeContextBudget(maxContextSize, langScale)
+  const { maxCtx, responseReserve } = computeContextBudget(maxContextSize, charsPerToken)
   const stableReserve = Math.min(Math.floor(maxCtx * 0.25), Math.max(12_000, stableContextLength))
   const instructionReserve = Math.max(12_000, Math.floor(maxCtx * 0.08))
   const available = maxCtx - responseReserve - stableReserve - instructionReserve
@@ -1230,22 +1230,28 @@ export function computeIngestSourceBudget(
   return clampNumber(Math.floor(available), LONG_SOURCE_MIN_BUDGET, upper)
 }
 
+/**
+ * Output ladder for wiki page generation, stepped off the model's token
+ * window. Compares the window directly rather than a language-scaled
+ * character budget: a model's output ceiling does not shrink because the UI
+ * is in Chinese, and the old comparison dropped CJK users a whole tier.
+ */
 export function computeIngestGenerationMaxTokens(
   maxContextSize: number | undefined,
-  langScale?: number,
 ): number {
-  const { maxCtx } = computeContextBudget(maxContextSize, langScale)
-  if (maxCtx >= 512_000) return INGEST_GENERATION_TOKENS_512K
-  if (maxCtx >= 256_000) return INGEST_GENERATION_TOKENS_256K
-  if (maxCtx >= 128_000) return INGEST_GENERATION_TOKENS_128K
+  const windowTokens = typeof maxContextSize === "number" && maxContextSize > 0
+    ? maxContextSize
+    : DEFAULT_INGEST_WINDOW_TOKENS
+  if (windowTokens >= 512_000) return INGEST_GENERATION_TOKENS_512K
+  if (windowTokens >= 256_000) return INGEST_GENERATION_TOKENS_256K
+  if (windowTokens >= 128_000) return INGEST_GENERATION_TOKENS_128K
   return INGEST_GENERATION_TOKENS_DEFAULT
 }
 
 export function computeIngestReviewMaxTokens(
   maxContextSize: number | undefined,
-  langScale?: number,
 ): number {
-  return Math.min(8_192, Math.max(4_096, Math.floor(computeIngestGenerationMaxTokens(maxContextSize, langScale) / 2)))
+  return Math.min(8_192, Math.max(4_096, Math.floor(computeIngestGenerationMaxTokens(maxContextSize) / 2)))
 }
 
 /**
@@ -1257,13 +1263,14 @@ export function computeIngestReviewMaxTokens(
  */
 export function computeIngestAnalysisMaxTokens(
   maxContextSize: number | undefined,
-  langScale?: number,
 ): number {
-  return Math.min(8_192, Math.max(4_096, Math.floor(computeIngestGenerationMaxTokens(maxContextSize, langScale) / 2)))
+  return Math.min(8_192, Math.max(4_096, Math.floor(computeIngestGenerationMaxTokens(maxContextSize) / 2)))
 }
 
 /** chars/token the ingest budgeting assumes; mirrors context-budget.ts. */
 const INGEST_CHARS_PER_TOKEN = 4
+/** Window assumed when the config carries none; mirrors context-budget.ts. */
+const DEFAULT_INGEST_WINDOW_TOKENS = 204_800
 /** Smallest output allowance we will still request when the window is nearly
  *  full — below this a response is useless, so we accept a tiny overflow risk
  *  rather than emitting nothing. */
@@ -1274,19 +1281,19 @@ const INGEST_OUTPUT_TOKEN_FLOOR = 512
  * model's real token window. `desiredTokens` is the ladder value; we only ever
  * reduce it when the prompt already leaves less room than the ladder wants.
  *
- * Language-aware: CJK text is ~2.3x denser, so the same prompt consumes more
- * real tokens and leaves less room for output. The raw (unscaled) window is
- * the real token capacity (English-calibrated 4:1); the effective scale
- * recovers the true chars/token for the active language.
+ * Language-aware: CJK text is denser, so the same prompt consumes more real
+ * tokens and leaves less room for output. The English-calibrated window (4:1)
+ * recovers the real token capacity; the ratio against the active language's
+ * window recovers that language's true chars/token.
  */
 export function fitIngestOutputToWindow(
   maxContextSize: number | undefined,
   promptChars: number,
   desiredTokens: number,
-  langScale?: number,
+  charsPerToken?: number,
 ): number {
-  const rawWindow = computeContextBudget(maxContextSize, 1).maxCtx
-  const scaledWindow = computeContextBudget(maxContextSize, langScale).maxCtx
+  const rawWindow = computeContextBudget(maxContextSize, INGEST_CHARS_PER_TOKEN).maxCtx
+  const scaledWindow = computeContextBudget(maxContextSize, charsPerToken).maxCtx
   const scale = rawWindow > 0 ? scaledWindow / rawWindow : 1
   const windowTokens = rawWindow / INGEST_CHARS_PER_TOKEN
   const inputTokens = promptChars / (INGEST_CHARS_PER_TOKEN * scale)

+ 36 - 11
src/lib/llm-client.ts

@@ -2,8 +2,10 @@ import type { LlmConfig } from "@/stores/wiki-store"
 import { isAzureOpenAiEndpoint } from "@/lib/azure-openai"
 import {
   getEffectiveMaxContextSize,
+  getEffectiveMaxOutputTokens,
   getProviderConfig,
   isTruncationFinishReason,
+  thinkingMinMaxTokens,
   type RequestOverrides,
 } from "./llm-providers"
 import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
@@ -21,7 +23,7 @@ import {
   estimateRequestScaffoldTokens,
   trimChatMessagesToTokenBudget,
 } from "./chat-request-budget"
-import { LlmContextBudgetError, planLlmRequestBudget } from "./context-budget"
+import { RESPONSE_RESERVE_FRAC, planLlmRequestBudget } from "./context-budget"
 import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 import { applyGlobalUserMemoryToMessages } from "./user-memory/request-integration"
 
@@ -135,8 +137,9 @@ function parseToolCallDeltaFromLine(line: string): { index: number; id?: string;
       name: toolCall.function?.name,
       arguments: toolCall.function?.arguments,
     }
-  } catch (error) {
-    if (!(error instanceof LlmContextBudgetError)) throw error
+  } catch {
+    // A malformed SSE line is not fatal: skip it and keep the stream alive.
+    // The only error reachable here is JSON.parse's SyntaxError.
     return null
   }
 }
@@ -175,14 +178,23 @@ export async function streamChat(
   // Apply model-specific context size minimums (e.g. DeepSeek → 1M)
   const configuredWindow = getEffectiveMaxContextSize(runtimeConfig)
   const toolScaffoldTokens = estimateRequestScaffoldTokens(requestOverrides?.tools)
+  const outputCap = getEffectiveMaxOutputTokens(runtimeConfig)
   const runtimeBudget = planLlmRequestBudget({
     maxContextSize: configuredWindow,
+    // Without an explicit request the response reserve is only used to size
+    // the INPUT trim; it is not sent as max_tokens (see below).
     desiredOutputTokens: requestOverrides?.max_tokens
-      ?? Math.floor(configuredWindow * 0.15 / 4),
+      ?? Math.floor(configuredWindow * RESPONSE_RESERVE_FRAC),
     scaffoldReserveTokens: toolScaffoldTokens,
     minimumContextTokens: 64,
+    maxOutputTokensCap: outputCap,
+    thinkingFloorTokens: thinkingMinMaxTokens(runtimeConfig.reasoning ?? { mode: "auto" }),
   })
   let effectiveOutputTokens = runtimeBudget.outputTokens
+  // Only surface max_tokens when the caller asked for one. Inventing a value
+  // would replace the provider's own default with our estimate, capping every
+  // long-form generation that deliberately left it unset.
+  let shouldSendMaxTokens = requestOverrides?.max_tokens !== undefined
   let budgetedMessages: import("./llm-providers").ChatMessage[]
   try {
     budgetedMessages = trimChatMessagesToTokenBudget(
@@ -209,11 +221,13 @@ export async function streamChat(
           - estimateChatMessagesTokens(budgetedMessages),
       ),
     )
+    // The input was trimmed against a reserved output slot, so that slot has
+    // to be declared even if the caller never asked for one.
+    shouldSendMaxTokens = true
   }
-  const effectiveRequestOverrides: RequestOverrides = {
-    ...requestOverrides,
-    max_tokens: effectiveOutputTokens,
-  }
+  const effectiveRequestOverrides: RequestOverrides = shouldSendMaxTokens
+    ? { ...requestOverrides, max_tokens: effectiveOutputTokens }
+    : { ...requestOverrides }
   const { onToken, onDone, onError } = callbacks
   const decoder = new TextDecoder()
 
@@ -366,14 +380,25 @@ export async function streamChat(
       const inputLimit = parseInputLengthLimit(errorDetail)
       if (inputLimit) {
         const currentInputTokens = estimateChatMessagesTokens(budgetedMessages)
+        // The provider reports the overshoot in characters; we trim in tokens.
+        // Applying the ratio across units is a heuristic, not an exact
+        // conversion — it only has to land us under the limit, and the 0.85
+        // factor absorbs the imprecision.
         const shrinkRatio = Math.min(1, inputLimit.maxLength / Math.max(1, inputLimit.inputLength))
         const retryInputTokenBudget = Math.max(
           1,
           Math.floor(currentInputTokens * shrinkRatio * 0.85),
         )
-        const retryRequestInit = buildRequestInit(
-          trimChatMessagesToTokenBudget(budgetedMessages, retryInputTokenBudget),
-        )
+        let retryMessages: import("./llm-providers").ChatMessage[]
+        try {
+          retryMessages = trimChatMessagesToTokenBudget(budgetedMessages, retryInputTokenBudget)
+        } catch {
+          // Even the protected messages exceed the provider's limit; there is
+          // nothing left to shrink, so report the original limit.
+          onError(new Error(inputLengthLimitMessage(inputLimit)))
+          return
+        }
+        const retryRequestInit = buildRequestInit(retryMessages)
         if (retryRequestInit.body === requestInit.body) {
           onError(new Error(inputLengthLimitMessage(inputLimit)))
           return

+ 73 - 5
src/lib/llm-client.usage.spec.ts

@@ -111,7 +111,9 @@ describe("streamChat usage", () => {
       "",
     ].join("\n"), { status: 200 }))
 
-    await streamChat({ ...config, maxContextSize: 4_096 }, [
+    // 1843-token window (2048 × 0.9) against ~1800 tokens of CJK input, so the
+    // trim has to bite while leaving the protected messages intact.
+    await streamChat({ ...config, maxContextSize: 2_048 }, [
       { role: "system", content: "系统".repeat(450) },
       { role: "user", content: `任务目标:续写。${"正文".repeat(450)}结尾限制:保持人物关系。` },
     ], {
@@ -123,17 +125,16 @@ describe("streamChat usage", () => {
     const request = mocks.fetch.mock.calls[0][1] as RequestInit
     const body = JSON.parse(String(request.body)) as {
       messages: ChatMessage[]
-      max_tokens: number
+      max_tokens?: number
     }
-    expect(estimateChatMessagesTokens(body.messages)).toBeLessThanOrEqual(512)
-    expect(body.max_tokens).toBe(512)
+    expect(estimateChatMessagesTokens(body.messages)).toBeLessThanOrEqual(1_331)
     expect(String(body.messages[0]?.content).trim()).not.toBe("")
     expect(body.messages.at(-1)?.content).toContain("任务目标")
     expect(body.messages.at(-1)?.content).toContain("保持人物关系")
   })
 
   it("上下文无法容纳最小输出时明确失败且不调用供应商", async () => {
-    await expect(streamChat({ ...config, maxContextSize: 2_000 }, [
+    await expect(streamChat({ ...config, maxContextSize: 512 }, [
       { role: "system", content: "系统约束" },
       { role: "user", content: "生成第一卷完整大纲" },
     ], {
@@ -144,4 +145,71 @@ describe("streamChat usage", () => {
 
     expect(mocks.fetch).not.toHaveBeenCalled()
   })
+
+  it("调用方未传 max_tokens 时请求体不带该字段", async () => {
+    mocks.fetch.mockResolvedValue(new Response([
+      'data: {"choices":[{"delta":{"content":"完成"}}]}',
+      "data: [DONE]",
+      "",
+    ].join("\n"), { status: 200 }))
+
+    await streamChat(config, [{ role: "user", content: "写第一章" }], {
+      onToken: vi.fn(),
+      onDone: vi.fn(),
+      onError: vi.fn(),
+    })
+
+    const request = mocks.fetch.mock.calls[0][1] as RequestInit
+    expect(JSON.parse(String(request.body))).not.toHaveProperty("max_tokens")
+  })
+
+  it("调用方显式传入的超大 max_tokens 收敛到输出上限", async () => {
+    mocks.fetch.mockResolvedValue(new Response([
+      'data: {"choices":[{"delta":{"content":"完成"}}]}',
+      "data: [DONE]",
+      "",
+    ].join("\n"), { status: 200 }))
+
+    await streamChat(
+      { ...config, maxContextSize: 1_000_000, maxOutputTokens: 65_536 },
+      [{ role: "user", content: "写第一章" }],
+      { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+      undefined,
+      { max_tokens: 300_000 },
+    )
+
+    const request = mocks.fetch.mock.calls[0][1] as RequestInit
+    expect(JSON.parse(String(request.body))).toMatchObject({ max_tokens: 65_536 })
+  })
+
+  it("脏 SSE 行不会中断整轮流式响应", async () => {
+    const encoder = new TextEncoder()
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(encoder.encode([
+          'data: {"choices":[{"delta":{"content":"前半"}}]}',
+          "data: {不是合法 JSON",
+          'data: {"choices":[{"delta":{"content":"后半"}}]}',
+          "data: [DONE]",
+          "",
+        ].join("\n")))
+        controller.close()
+      },
+    })
+    mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
+    const onToken = vi.fn()
+    const onDone = vi.fn()
+    const onError = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "写第一章" }], {
+      onToken,
+      onDone,
+      onError,
+    })
+
+    expect(onToken).toHaveBeenCalledWith("前半")
+    expect(onToken).toHaveBeenCalledWith("后半")
+    expect(onDone).toHaveBeenCalledOnce()
+    expect(onError).not.toHaveBeenCalled()
+  })
 })

+ 42 - 7
src/lib/llm-context-size.ts

@@ -2,6 +2,15 @@ import type { LlmConfig, ProviderConfigs, ProviderOverride } from "@/stores/wiki
 
 export const MIN_USER_LLM_CONTEXT_SIZE = 204_800
 
+/** Default declared output ceiling when neither the user nor the preset says
+ *  otherwise. Generous on purpose — it must not silently truncate capable
+ *  models — so presets should carry a real figure wherever one is known. */
+export const DEFAULT_USER_LLM_MAX_OUTPUT_TOKENS = 131_072
+/** Below this an answer is not worth requesting. */
+export const MIN_USER_LLM_MAX_OUTPUT_TOKENS = 512
+/** Highest output any model in the catalog declares (DeepSeek V4: 384K). */
+export const MAX_USER_LLM_MAX_OUTPUT_TOKENS = 393_216
+
 export function normalizeUserLlmContextSize(value: number | undefined): number {
   if (!Number.isFinite(value) || (value as number) <= 0) {
     return MIN_USER_LLM_CONTEXT_SIZE
@@ -9,19 +18,45 @@ export function normalizeUserLlmContextSize(value: number | undefined): number {
   return Math.max(MIN_USER_LLM_CONTEXT_SIZE, Math.floor(value as number))
 }
 
+/**
+ * Unlike the context window this has no floor, only a default: a user must be
+ * able to declare a small ceiling for a model that really does cap out low.
+ */
+export function normalizeUserLlmMaxOutputTokens(value: number | undefined): number {
+  if (!Number.isFinite(value) || (value as number) <= 0) {
+    return DEFAULT_USER_LLM_MAX_OUTPUT_TOKENS
+  }
+  return Math.max(
+    MIN_USER_LLM_MAX_OUTPUT_TOKENS,
+    Math.min(MAX_USER_LLM_MAX_OUTPUT_TOKENS, Math.floor(value as number)),
+  )
+}
+
 export function normalizeUserLlmConfig(config: LlmConfig): LlmConfig {
   const maxContextSize = normalizeUserLlmContextSize(config.maxContextSize)
-  return maxContextSize === config.maxContextSize
+  const maxOutputTokens = config.maxOutputTokens === undefined
+    ? undefined
+    : normalizeUserLlmMaxOutputTokens(config.maxOutputTokens)
+  return maxContextSize === config.maxContextSize && maxOutputTokens === config.maxOutputTokens
     ? config
-    : { ...config, maxContextSize }
+    : { ...config, maxContextSize, ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }) }
 }
 
 export function normalizeProviderOverride(override: ProviderOverride): ProviderOverride {
-  if (override.maxContextSize === undefined) return override
-  const maxContextSize = normalizeUserLlmContextSize(override.maxContextSize)
-  return maxContextSize === override.maxContextSize
-    ? override
-    : { ...override, maxContextSize }
+  const maxContextSize = override.maxContextSize === undefined
+    ? undefined
+    : normalizeUserLlmContextSize(override.maxContextSize)
+  const maxOutputTokens = override.maxOutputTokens === undefined
+    ? undefined
+    : normalizeUserLlmMaxOutputTokens(override.maxOutputTokens)
+  if (maxContextSize === override.maxContextSize && maxOutputTokens === override.maxOutputTokens) {
+    return override
+  }
+  return {
+    ...override,
+    ...(maxContextSize === undefined ? {} : { maxContextSize }),
+    ...(maxOutputTokens === undefined ? {} : { maxOutputTokens }),
+  }
 }
 
 export function normalizeProviderConfigs(configs: ProviderConfigs): ProviderConfigs {

+ 56 - 15
src/lib/llm-providers.spec.ts

@@ -165,31 +165,28 @@ describe("llm provider reasoning options", () => {
     expect(body).not.toHaveProperty("thinking")
   })
 
-  it("boosts max_tokens for MiMo when thinking is enabled without explicit max_tokens", () => {
+  it("leaves max_tokens absent for MiMo thinking when the caller did not set one", () => {
     const body = requestBody(customConfig({
       model: "mimo-v2.5-pro",
       reasoning: { mode: "high" },
     }))
 
-    expect(body.max_tokens).toBe(16384)
+    expect(body).not.toHaveProperty("max_tokens")
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: true })
   })
 
-  it("boosts max_tokens for MiMo via endpoint detection", () => {
+  it("leaves max_tokens absent for MiMo detected by endpoint", () => {
     const body = requestBody(customConfig({
       model: "custom-alias",
       customEndpoint: "https://token-plan-cn.xiaomimimo.com/v1",
       reasoning: { mode: "medium" },
     }))
 
-    expect(body.max_tokens).toBe(8192)
+    expect(body).not.toHaveProperty("max_tokens")
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: true })
   })
 
-  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
+  it("never rewrites an explicit max_tokens for MiMo thinking", () => {
     const bodyWithOverride = getProviderConfig(customConfig({
       model: "mimo-v2.5-pro",
       reasoning: { mode: "high" },
@@ -199,7 +196,36 @@ describe("llm provider reasoning options", () => {
     ) as Record<string, unknown>
 
     expect(bodyWithOverride.max_tokens).toBe(32000)
-    expect(body.max_tokens).toBe(16384)
+    expect(bodyWithOverride.chat_template_kwargs).toEqual({ enable_thinking: true })
+  })
+
+  it("turns MiMo thinking off when the planned output cannot hold it", () => {
+    const body = getProviderConfig(customConfig({
+      model: "mimo-v2.5-pro",
+      reasoning: { mode: "high" },
+    })).buildBody(
+      [{ role: "user", content: "test" }],
+      { max_tokens: 2048 },
+    ) as Record<string, unknown>
+
+    expect(body.max_tokens).toBe(2048)
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: false })
+    expect(body).not.toHaveProperty("reasoning_effort")
+  })
+
+  it("turns GLM-5 thinking off when the planned output cannot hold it", () => {
+    const body = getProviderConfig(customConfig({
+      model: "glm-5-plus",
+      customEndpoint: "https://open.bigmodel.cn/api/paas/v4",
+      reasoning: { mode: "high" },
+    })).buildBody(
+      [{ role: "user", content: "test" }],
+      { max_tokens: 2048 },
+    ) as Record<string, unknown>
+
+    expect(body.max_tokens).toBe(2048)
+    expect(body.thinking).toEqual({ type: "disabled" })
+    expect(body).not.toHaveProperty("reasoning_effort")
   })
 
   it("does not set max_tokens for MiMo when thinking is off", () => {
@@ -220,23 +246,38 @@ describe("llm provider reasoning options", () => {
     expect(body).not.toHaveProperty("max_tokens")
   })
 
-  it("boosts max_tokens for Qwen3 thinking at medium level", () => {
+  it("leaves max_tokens absent for Qwen3 thinking at medium level", () => {
     const body = requestBody(customConfig({
       model: "qwen3-235b-a22b",
       reasoning: { mode: "medium" },
     }))
 
-    expect(body.max_tokens).toBe(8192)
+    expect(body).not.toHaveProperty("max_tokens")
+    expect(body.chat_template_kwargs).toEqual({ enable_thinking: true })
   })
 
-  it("boosts max_tokens for DeepSeek thinking at low level", () => {
+  it("leaves max_tokens absent 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)
+    expect(body).not.toHaveProperty("max_tokens")
+  })
+
+  it("turns DeepSeek thinking off when the planned output cannot hold it", () => {
+    const body = getProviderConfig(customConfig({
+      model: "deepseek-v4-flash",
+      reasoning: { mode: "high" },
+    })).buildBody(
+      [{ role: "user", content: "test" }],
+      { max_tokens: 2048 },
+    ) as Record<string, unknown>
+
+    expect(body.max_tokens).toBe(2048)
+    expect(body.thinking).toEqual({ type: "disabled" })
+    expect(body).not.toHaveProperty("reasoning_effort")
   })
 
   it.each<ReasoningMode>(["max", "custom"])("maps Responses API %s reasoning to high effort", (mode) => {

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

@@ -5,6 +5,10 @@ import {
   isAzureOpenAiEndpoint,
 } from "@/lib/azure-openai"
 import { normalizeEndpoint } from "@/lib/endpoint-normalizer"
+import {
+  MIN_USER_LLM_CONTEXT_SIZE,
+  normalizeUserLlmMaxOutputTokens,
+} from "@/lib/llm-context-size"
 import type { LlmUsage } from "./llm-usage"
 import type { UserMemorySurface } from "./user-memory/types"
 
@@ -603,16 +607,20 @@ function reasoningEffort(reasoning: ReasoningConfig): "low" | "medium" | "high"
 }
 
 /**
- * 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.
+ * Total output tokens (thinking + final answer) a reasoning level needs in
+ * order to produce anything useful. Below this the model spends the whole
+ * allowance on `reasoning_content` and returns zero `content`, which surfaces
+ * as the "思考上限" error.
  *
- * Mirrors the protection already present in `buildAnthropicBodyWithReasoning`
- * (budget_tokens + 4096 answer reserve).
+ * This is a pure query. Two consumers act on it, both *before* the request
+ * body is built: `planLlmRequestBudget` raises the planned output to this
+ * floor (still bounded by the user's output cap and the context window), and
+ * the settings UI raises the user's configured output cap when they pick a
+ * reasoning level that needs more. Nothing may inflate `max_tokens` at
+ * body-build time — that happens after budgeting and would break the
+ * window conservation the planner just established.
  */
-function thinkingMinMaxTokens(reasoning: ReasoningConfig): number {
+export function thinkingMinMaxTokens(reasoning: ReasoningConfig): number {
   switch (reasoning.mode) {
     case "low":
       return 4096
@@ -631,12 +639,24 @@ function thinkingMinMaxTokens(reasoning: ReasoningConfig): number {
   }
 }
 
-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
-  }
+/**
+ * Whether explicit thinking can be honoured within the output allowance the
+ * caller already decided on. An absent `max_tokens` means the provider
+ * default applies and we have no basis to judge, so thinking stays on.
+ *
+ * OpenAI-compatible endpoints expose thinking as a boolean with no budget
+ * field, so the only remedy when it does not fit is to turn thinking off —
+ * unlike the Anthropic path, which can shrink `budget_tokens` instead.
+ */
+function thinkingFitsInOutputBudget(
+  body: Record<string, unknown>,
+  reasoning: ReasoningConfig,
+): boolean {
+  const required = thinkingMinMaxTokens(reasoning)
+  if (required <= 0) return true
+  const planned = body.max_tokens
+  if (typeof planned !== "number") return true
+  return planned >= required
 }
 
 function isDeepSeekEndpoint(config: LlmConfig): boolean {
@@ -644,22 +664,22 @@ function isDeepSeekEndpoint(config: LlmConfig): boolean {
 }
 
 /**
- * Minimum context window for DeepSeek models. DeepSeek V3/V4 support
- * up to 1M tokens; the previous default of 64K/200K caused response
- * truncation on long inputs (bug report).
- */
-const DEEPSEEK_MIN_CONTEXT_SIZE = 1_000_000
-
-/**
- * Returns the effective maxContextSize for a given config, applying
- * model-specific minimums. DeepSeek endpoints get bumped to at least
- * 1M chars so long prompts aren't silently truncated.
+ * The context window to plan against, in tokens.
+ *
+ * Deliberately just the user's setting plus a fallback. Model-specific
+ * minimums used to be forced here, which meant the value in the settings UI
+ * and the value actually used could differ with nothing on screen to say so —
+ * for DeepSeek the window slider had no effect at all. Model defaults belong
+ * in the presets (`suggestedContextSize`), where the user can see and change
+ * them.
  */
 export function getEffectiveMaxContextSize(config: LlmConfig): number {
-  if (isDeepSeekEndpoint(config)) {
-    return Math.max(config.maxContextSize || 0, DEEPSEEK_MIN_CONTEXT_SIZE)
-  }
-  return config.maxContextSize || 204_800
+  return config.maxContextSize || MIN_USER_LLM_CONTEXT_SIZE
+}
+
+/** The declared output ceiling to plan against, in tokens. */
+export function getEffectiveMaxOutputTokens(config: LlmConfig): number {
+  return normalizeUserLlmMaxOutputTokens(config.maxOutputTokens)
 }
 
 /**
@@ -781,8 +801,11 @@ function buildOpenAiCompatibleBody(
     if (reasoning.mode === "off") {
       body.thinking = { type: "disabled" }
     } else if (reasoning.mode !== "auto") {
+      if (!thinkingFitsInOutputBudget(body, reasoning)) {
+        body.thinking = { type: "disabled" }
+        return body
+      }
       body.thinking = { type: "enabled" }
-      ensureMinMaxTokens(body, thinkingMinMaxTokens(reasoning))
       const effort = reasoningEffort(reasoning)
       if (effort) {
         body.reasoning_effort = effort
@@ -791,14 +814,18 @@ function buildOpenAiCompatibleBody(
     return body
   }
 
+  // 思考放不下时改为关闭,同时压掉 reasoning_effort,避免请求体自相矛盾
+  let thinkingSuppressed = false
+
   // 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))
+      const fits = thinkingFitsInOutputBudget(body, reasoning)
+      body.chat_template_kwargs = { enable_thinking: fits }
+      if (!fits) thinkingSuppressed = true
     }
   }
 
@@ -807,13 +834,18 @@ function buildOpenAiCompatibleBody(
     if (reasoning.mode === "off") {
       body.thinking = { type: "disabled" }
     } else if (reasoning.mode !== "auto") {
-      body.thinking = { type: "enabled" }
-      ensureMinMaxTokens(body, thinkingMinMaxTokens(reasoning))
+      const fits = thinkingFitsInOutputBudget(body, reasoning)
+      body.thinking = { type: fits ? "enabled" : "disabled" }
+      if (!fits) thinkingSuppressed = true
     }
   }
 
   const effort = reasoningEffort(reasoning)
-  if ((config.provider === "openai" || config.provider === "azure" || config.provider === "custom") && effort) {
+  if (
+    !thinkingSuppressed
+    && (config.provider === "openai" || config.provider === "azure" || config.provider === "custom")
+    && effort
+  ) {
     body.reasoning_effort = effort
   }
 

+ 38 - 2
src/lib/novel/deep-chapter-generation.spec.ts

@@ -1235,7 +1235,13 @@ describe("runDeepChapterGeneration", () => {
     })
 
     await runDeepChapterGeneration(
-      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig },
+      {
+        projectPath: "E:/Novel",
+        userRequest: "生成第三章",
+        chapterNumber: 3,
+        // Auto reasoning carries no output floor, so the per-stage budgets show through.
+        llmConfig: { ...llmConfig, reasoning: { mode: "auto" } },
+      },
       {},
       deps,
     )
@@ -1246,6 +1252,34 @@ describe("runDeepChapterGeneration", () => {
     expect(overrides.some((item) => item?.max_tokens === 8_000)).toBe(true)
   })
 
+  it("raises stage output to the floor the configured reasoning level needs", async () => {
+    const deps = createDeps()
+    const overrides: Array<RequestOverrides | undefined> = []
+    vi.mocked(deps.streamChat).mockImplementation(async (
+      _config: LlmConfig,
+      messages: ChatMessage[],
+      callbacks: StreamCallbacks,
+      _signal,
+      requestOverrides,
+    ) => {
+      overrides.push(requestOverrides)
+      const prompt = messagesPromptText(messages)
+      callbacks.onToken(prompt.includes("正文") ? chapterText("思考档位正文", 3000) : "写作任务书内容")
+      callbacks.onDone()
+    })
+
+    await runDeepChapterGeneration(
+      // llmConfig requests "high" reasoning, which needs 16384 output tokens
+      // before it can emit any final content.
+      { projectPath: "E:/Novel", userRequest: "生成第三章", chapterNumber: 3, llmConfig },
+      {},
+      deps,
+    )
+
+    expect(overrides.length).toBeGreaterThan(0)
+    expect(overrides.every((item) => item?.max_tokens === 16_384)).toBe(true)
+  })
+
   it("preserves configured model reasoning for chapter generation calls", async () => {
     const deps = createDeps()
     const overrides: Array<RequestOverrides | undefined> = []
@@ -1312,7 +1346,9 @@ describe("runDeepChapterGeneration", () => {
     expect(result.finalContent).toContain("最终兜底正文")
     expect(overrides[0]?.reasoning).toBeUndefined()
     expect(overrides[1]).toEqual({
-      max_tokens: 4_096,
+      // Budgets are planned once per run, from the configured "high" reasoning
+      // level; the retry only turns thinking off, and max_tokens is a ceiling.
+      max_tokens: 16_384,
       reasoning: { mode: "off" },
     })
   })

+ 12 - 0
src/lib/novel/deep-chapter-generation.ts

@@ -14,6 +14,10 @@ import {
   withReasoningDisabled,
 } from "@/lib/reasoning-retry";
 import { planChapterRequestBudget } from "@/lib/context-budget";
+import {
+  getEffectiveMaxOutputTokens,
+  thinkingMinMaxTokens,
+} from "@/lib/llm-providers";
 import { USER_ABORT_MESSAGE, rethrowIfUserAbort, throwIfAborted } from "@/lib/user-abort";
 import {
   buildContextPack,
@@ -667,17 +671,25 @@ export async function runDeepChapterGeneration(
     : input.llmConfig.maxContextSize;
 
   // 大纲与其余上下文共用同一窗口预算:按单章目标字数×2预留输出,再分配资料包。
+  const sharedMaxOutputTokens = getEffectiveMaxOutputTokens(input.llmConfig);
+  const sharedThinkingFloor = thinkingMinMaxTokens(
+    input.llmConfig.reasoning ?? { mode: "auto" },
+  );
   const chapterAnalysisBudget = planChapterRequestBudget({
     maxContextSize: sharedContextWindow,
     contextTokenBudget: novelConfig.contextTokenBudget,
     chapterTargetChars: novelConfig.chapterTargetChars,
     stage: "analysis",
+    maxOutputTokens: sharedMaxOutputTokens,
+    thinkingFloorTokens: sharedThinkingFloor,
   });
   const chapterGenerationBudget = planChapterRequestBudget({
     maxContextSize: sharedContextWindow,
     contextTokenBudget: novelConfig.contextTokenBudget,
     chapterTargetChars: novelConfig.chapterTargetChars,
     stage: "generation",
+    maxOutputTokens: sharedMaxOutputTokens,
+    thinkingFloorTokens: sharedThinkingFloor,
   });
   const totalContextTokenBudget = chapterGenerationBudget.contextTokenBudget;
   const analysisRequestOverrides: RequestOverrides = {

+ 6 - 2
src/lib/novel/model-resolver.ts

@@ -2,7 +2,7 @@ import { useWikiStore, type LlmConfig, type NovelConfig, type ProviderOverride }
 import { findLlmPresetById } from "@/components/settings/llm-presets"
 import { resolveConfig } from "@/components/settings/preset-resolver"
 import { hasUsableLlm } from "@/lib/has-usable-llm"
-import { getEffectiveMaxContextSize } from "@/lib/llm-providers"
+import { getEffectiveMaxContextSize, getEffectiveMaxOutputTokens } from "@/lib/llm-providers"
 import { getStableAvailableModelKey, getEffectiveSavedModels } from "@/lib/llm-model-keys"
 import { normalizeUserLlmConfig } from "@/lib/llm-context-size"
 
@@ -20,7 +20,11 @@ function isConfigUsable(cfg: LlmConfig, providerConfigs: Record<string, Provider
 
 function withEffectiveContextSize(config: LlmConfig): LlmConfig {
   const normalized = normalizeUserLlmConfig(config)
-  return { ...normalized, maxContextSize: getEffectiveMaxContextSize(normalized) }
+  return {
+    ...normalized,
+    maxContextSize: getEffectiveMaxContextSize(normalized),
+    maxOutputTokens: getEffectiveMaxOutputTokens(normalized),
+  }
 }
 
 function toUnusableConfig(baseConfig: LlmConfig): LlmConfig {

+ 47 - 0
src/lib/project-store.integration.test.ts

@@ -79,6 +79,53 @@ it("silently migrates legacy main and provider context sizes on load", async ()
   expect((inMemoryStore.get("providerConfigs") as ProviderConfigs).custom?.enabled).toBe(false)
 })
 
+describe("DeepSeek window migration", () => {
+  it("lifts a stale saved window to 1M once, then leaves the user in control", async () => {
+    // The runtime used to force DeepSeek to 1M, hiding whatever was saved.
+    // Removing that forcing would expose these stale values, so they are
+    // lifted once — after which a deliberate reduction must stick.
+    inMemoryStore.set("llmConfig", {
+      provider: "custom",
+      apiKey: "key",
+      model: "deepseek-chat",
+      customEndpoint: "https://api.deepseek.com/v1",
+      ollamaUrl: "",
+      maxContextSize: 262_144,
+    } satisfies LlmConfig)
+    inMemoryStore.set("providerConfigs", {
+      deepseek: { apiKey: "key", model: "deepseek-chat", maxContextSize: 262_144 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.maxContextSize).toBe(1_000_000)
+    expect((await loadProviderConfigs())?.deepseek?.maxContextSize).toBe(1_000_000)
+
+    inMemoryStore.set("llmConfig", {
+      ...(inMemoryStore.get("llmConfig") as LlmConfig),
+      maxContextSize: 262_144,
+    })
+    inMemoryStore.set("providerConfigs", {
+      deepseek: { apiKey: "key", model: "deepseek-chat", maxContextSize: 262_144 },
+    } satisfies ProviderConfigs)
+
+    expect((await loadLlmConfig())?.maxContextSize).toBe(262_144)
+    expect((await loadProviderConfigs())?.deepseek?.maxContextSize).toBe(262_144)
+  })
+
+  it("leaves third-party hosts serving DeepSeek models alone", async () => {
+    // 1M is DeepSeek's own figure; gateways reselling the model set their own.
+    inMemoryStore.set("llmConfig", {
+      provider: "custom",
+      apiKey: "key",
+      model: "deepseek-ai/deepseek-v4-pro",
+      customEndpoint: "https://api.atlascloud.ai/v1",
+      ollamaUrl: "",
+      maxContextSize: 262_144,
+    } satisfies LlmConfig)
+
+    expect((await loadLlmConfig())?.maxContextSize).toBe(262_144)
+  })
+})
+
 function makeNovelConfig(overrides: Partial<NovelConfig> = {}): NovelConfig {
   return {
     contextTokenBudget: 200000,

+ 68 - 2
src/lib/project-store.ts

@@ -51,6 +51,49 @@ export async function addToRecentProjects(
 }
 
 const LLM_CONFIG_KEY = "llmConfig"
+// Separate markers per store slot: the two loaders run independently and in no
+// guaranteed order, so a shared marker would let whichever ran first cancel the
+// other's migration.
+const DEEPSEEK_WINDOW_MIGRATION_KEYS = {
+  llmConfig: "deepseekWindowMigratedV1.llmConfig",
+  providerConfigs: "deepseekWindowMigratedV1.providerConfigs",
+} as const
+/** DeepSeek's official published context window. */
+const DEEPSEEK_OFFICIAL_CONTEXT_SIZE = 1_000_000
+/** Preset id whose configuration is known to target api.deepseek.com. */
+const DEEPSEEK_PRESET_ID = "deepseek"
+
+function isDeepSeekOfficialEndpoint(endpoint: string | undefined): boolean {
+  return typeof endpoint === "string" && /api\.deepseek\.com/i.test(endpoint)
+}
+
+/**
+ * One-time lift of saved DeepSeek windows to the official 1M.
+ *
+ * The window used to be forced to 1M at request time, which hid whatever the
+ * user had actually saved. Now that the forcing is gone those stale values
+ * would take effect, so they get raised once — in the user's own settings,
+ * where they can see and change it. The marker makes this genuinely one-time:
+ * without it, anyone who deliberately lowered the window afterwards would find
+ * it raised again on every launch, which is the hardcoding we just removed.
+ *
+ * Scoped to DeepSeek's own endpoint. Third-party hosts serving DeepSeek models
+ * (Atlas Cloud, Ollama Cloud, Volcengine) set their own limits, and the 1M
+ * figure has no authority there.
+ */
+async function hasRunDeepSeekWindowMigration(
+  slot: keyof typeof DEEPSEEK_WINDOW_MIGRATION_KEYS,
+): Promise<boolean> {
+  const store = await getStore()
+  return (await store.get<boolean>(DEEPSEEK_WINDOW_MIGRATION_KEYS[slot])) === true
+}
+
+async function markDeepSeekWindowMigrationDone(
+  slot: keyof typeof DEEPSEEK_WINDOW_MIGRATION_KEYS,
+): Promise<void> {
+  const store = await getStore()
+  await store.set(DEEPSEEK_WINDOW_MIGRATION_KEYS[slot], true)
+}
 const AI_CHAT_MODEL_KEY = "aiChatModel"
 const AI_OUTLINE_MODEL_KEY = "aiOutlineModel"
 let aiOutlineModelSaveRevision = 0
@@ -68,7 +111,16 @@ export async function loadLlmConfig(): Promise<LlmConfig | null> {
   const store = await getStore()
   const saved = (await store.get<LlmConfig>(LLM_CONFIG_KEY)) ?? null
   if (!saved) return null
-  const normalized = normalizeUserLlmConfig(saved)
+  let normalized = normalizeUserLlmConfig(saved)
+  if (!(await hasRunDeepSeekWindowMigration("llmConfig"))) {
+    if (
+      isDeepSeekOfficialEndpoint(normalized.customEndpoint)
+      && normalized.maxContextSize < DEEPSEEK_OFFICIAL_CONTEXT_SIZE
+    ) {
+      normalized = { ...normalized, maxContextSize: DEEPSEEK_OFFICIAL_CONTEXT_SIZE }
+    }
+    await markDeepSeekWindowMigrationDone("llmConfig")
+  }
   if (normalized !== saved) await store.set(LLM_CONFIG_KEY, normalized)
   return normalized
 }
@@ -120,7 +172,21 @@ export async function loadProviderConfigs(): Promise<ProviderConfigs | null> {
   const store = await getStore()
   const saved = (await store.get<ProviderConfigs>(PROVIDER_CONFIGS_KEY)) ?? null
   if (!saved) return null
-  const normalized = normalizeProviderConfigs(saved)
+  let normalized = normalizeProviderConfigs(saved)
+  if (!(await hasRunDeepSeekWindowMigration("providerConfigs"))) {
+    const deepseek = normalized[DEEPSEEK_PRESET_ID]
+    if (
+      deepseek
+      && deepseek.maxContextSize !== undefined
+      && deepseek.maxContextSize < DEEPSEEK_OFFICIAL_CONTEXT_SIZE
+    ) {
+      normalized = {
+        ...normalized,
+        [DEEPSEEK_PRESET_ID]: { ...deepseek, maxContextSize: DEEPSEEK_OFFICIAL_CONTEXT_SIZE },
+      }
+    }
+    await markDeepSeekWindowMigrationDone("providerConfigs")
+  }
   if (normalized !== saved) await store.set(PROVIDER_CONFIGS_KEY, normalized)
   return normalized
 }

+ 6 - 1
src/stores/wiki-store.ts

@@ -144,7 +144,11 @@ interface LlmConfig {
   customEndpoint: string
   azureApiVersion?: string
   azureModelFamily?: AzureModelFamily
-  maxContextSize: number // max context window in characters
+  /** The model's context window, in TOKENS, as published on its spec sheet. */
+  maxContextSize: number
+  /** The model's maximum output, in TOKENS. A capability ceiling, not a
+   *  per-request size: what actually gets sent is min(workflow need, this). */
+  maxOutputTokens?: number
   apiMode?: CustomApiMode
   reasoning?: ReasoningConfig
   localCliIsolation?: boolean
@@ -447,6 +451,7 @@ export interface ProviderOverride {
   azureModelFamily?: AzureModelFamily
   apiMode?: CustomApiMode
   maxContextSize?: number
+  maxOutputTokens?: number
   reasoning?: ReasoningConfig
   localCliIsolation?: boolean
   codexCliTimeoutMinutes?: number