Bläddra i källkod

feat: 优化缓存命中统计与供应商用量解析

Mochocyang 2 månader sedan
förälder
incheckning
2568bae1f3

+ 2 - 2
package-lock.json

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

+ 1 - 1
package.json

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

+ 1 - 1
src-tauri/Cargo.lock

@@ -5728,7 +5728,7 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
 
 [[package]]
 name = "qmai"
-version = "2.2.36"
+version = "2.2.37"
 dependencies = [
  "arrow-array",
  "arrow-schema",

+ 1 - 1
src-tauri/Cargo.toml

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

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

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

+ 19 - 0
src/components/chat/chat-panel.tsx

@@ -122,6 +122,7 @@ import {
   buildSessionContextSummary,
   flattenContextHubSystemContent,
   getContextHub,
+  persistContextHubProviderUsage,
   selectContextHistoryMessages,
   type ContextHubResult,
   type ContextIntent,
@@ -1739,6 +1740,24 @@ export function ChatPanel() {
 
         if (controller.signal.aborted) return
         if (!streamSessionGuardRef.current.isActive(capturedConvId, sessionId)) return
+        if (contextHubResult && record.usage) {
+          try {
+            const contextHubSnapshot = await persistContextHubProviderUsage(
+              getContextHub(pp),
+              assistantMessage.id,
+              contextHubResult,
+              record.usage,
+            )
+            if (contextHubSnapshot) {
+              updateAgentAssistantMessage(assistantMessage.id, (message) => ({
+                ...message,
+                contextHubSnapshot,
+              }))
+            }
+          } catch (error) {
+            console.warn("供应商缓存用量快照保存失败,继续保留本地缓存统计:", error)
+          }
+        }
         finishAgentSession(() => {
           if (!hasAgentError) {
             if (contextTrace && effectiveTaskRoute) {

+ 9 - 5
src/components/chat/context-trace-panel.spec.tsx

@@ -40,12 +40,12 @@ describe("ContextTracePanel selected skills", () => {
 
     expect(html).toContain("上下文中控")
     expect(html).not.toContain("4ms")
-    expect(html).toContain("本地缓存:命中 4,刷新 1,失败 0")
+    expect(html).toContain("本轮缓存事件:命中 4,刷新 1,失败 0")
     expect(html).toContain("稳定核心 1,200 Token")
     expect(html).toContain("会话摘要 180 Token")
     expect(html).toContain("动态片段 420 Token")
-    expect(html).toContain("项目资料预计节省 1,400 Token(44%)")
-    expect(html).toContain("已启用稳定前缀缓存")
+    expect(html).toContain("上下文压缩预计减少 1,400 Token(44%)")
+    expect(html).toContain("已发送稳定前缀,是否命中以供应商返回为准")
     expect(html).not.toContain("供应商已确认命中")
   })
 
@@ -76,7 +76,10 @@ describe("ContextTracePanel selected skills", () => {
           estimatedSavedPercent: 33,
           expanded: true,
           providerCacheEnabled: true,
+          providerUsageReported: true,
+          providerInputTokens: 1536,
           providerCachedTokens: 768,
+          providerCacheWriteTokens: 256,
         },
       },
     }
@@ -84,7 +87,8 @@ describe("ContextTracePanel selected skills", () => {
     const html = renderToStaticMarkup(<ContextTracePanel trace={trace} />)
 
     expect(html).toContain("低置信度扩展:已启用")
-    expect(html).toContain("供应商已确认命中 768 Token")
+    expect(html).toContain("供应商已确认命中 768 Token(输入占比 50%)")
+    expect(html).toContain("供应商新写入缓存 256 Token")
   })
 
   it("uses the shared cache viewer when a persisted snapshot reference exists", () => {
@@ -127,7 +131,7 @@ describe("ContextTracePanel selected skills", () => {
     )
 
     expect(html).toContain("展开上下文中控")
-    expect(html).toContain("本地缓存:命中 2,刷新 1,失败 0")
+    expect(html).toContain("本轮缓存事件:命中 2,刷新 1,失败 0")
   })
 
   it("renders web search trace entries in the overview", () => {

+ 4 - 10
src/components/chat/context-trace-panel.tsx

@@ -40,7 +40,7 @@ import {
 } from "@/lib/novel/classification"
 import { cn } from "@/lib/utils"
 import { ToolCallTimeline } from "./tool-call-timeline"
-import { ContextHubDetails } from "@/components/common/context-hub-details"
+import { ContextHubDetails, ProviderCacheUsage } from "@/components/common/context-hub-details"
 import type { ContextHubSnapshotRef } from "@/lib/context-hub/types"
 
 interface RebuildRetrievalResult {
@@ -440,7 +440,7 @@ function OverviewTab({
             </div>
             <div className="ml-9 space-y-1 text-[11px] text-muted-foreground">
               <div>
-                本地缓存:命中 {contextInfo.contextHub.hits.toLocaleString()},刷新 {contextInfo.contextHub.refreshed.toLocaleString()},失败 {contextInfo.contextHub.failures.toLocaleString()}
+                本轮缓存事件:命中 {contextInfo.contextHub.hits.toLocaleString()},刷新 {contextInfo.contextHub.refreshed.toLocaleString()},失败 {contextInfo.contextHub.failures.toLocaleString()}
               </div>
               <div className="flex flex-wrap gap-x-3 gap-y-1">
                 <span>稳定核心 {contextInfo.contextHub.stableTokens.toLocaleString()} Token</span>
@@ -448,18 +448,12 @@ function OverviewTab({
                 <span>动态片段 {contextInfo.contextHub.dynamicTokens.toLocaleString()} Token</span>
               </div>
               <div>
-                项目资料预计节省 {contextInfo.contextHub.estimatedSavedTokens.toLocaleString()} Token({contextInfo.contextHub.estimatedSavedPercent}%)
+                上下文压缩预计减少 {contextInfo.contextHub.estimatedSavedTokens.toLocaleString()} Token({contextInfo.contextHub.estimatedSavedPercent}%)
               </div>
               <div>
                 低置信度扩展:{contextInfo.contextHub.expanded ? "已启用" : "未启用"}
               </div>
-              {contextInfo.contextHub.providerCachedTokens != null ? (
-                <div className="font-medium text-green-600 dark:text-green-400">
-                  供应商已确认命中 {contextInfo.contextHub.providerCachedTokens.toLocaleString()} Token
-                </div>
-              ) : contextInfo.contextHub.providerCacheEnabled ? (
-                <div>已启用稳定前缀缓存</div>
-              ) : null}
+              <ProviderCacheUsage stats={contextInfo.contextHub} />
             </div>
           </div>
         </>

+ 6 - 0
src/components/common/context-hub-details.spec.tsx

@@ -23,6 +23,10 @@ const snapshot: ContextHubSnapshot = {
     estimatedSavedPercent: 44,
     expanded: false,
     providerCacheEnabled: true,
+    providerUsageReported: true,
+    providerInputTokens: 1600,
+    providerCachedTokens: 800,
+    providerCacheWriteTokens: 200,
   },
   items: [
     {
@@ -93,6 +97,8 @@ describe("ContextHubDetails", () => {
     expect(host.textContent).toContain("稳定核心缓存")
     expect(host.textContent).toContain("wiki/outlines/main.md")
     expect(host.textContent).toContain("稳定核心正文")
+    expect(host.textContent).toContain("供应商已确认命中 800 Token(输入占比 50%)")
+    expect(host.textContent).toContain("供应商新写入缓存 200 Token")
     expect(host.innerHTML).toContain("max-h-96")
     expect(host.innerHTML).toContain("overflow-y-auto")
 

+ 34 - 7
src/components/common/context-hub-details.tsx

@@ -7,6 +7,7 @@ import type {
   ContextCacheItemTrace,
   ContextHubSnapshot,
   ContextHubSnapshotRef,
+  ContextHubStats,
 } from "@/lib/context-hub/types"
 
 type ContextSection = "stableCore" | "sessionSummary" | "dynamicContext"
@@ -88,6 +89,36 @@ function CacheItemGroup({ status, items }: { status: ContextCacheItemStatus; ite
   )
 }
 
+export function ProviderCacheUsage({ stats }: { stats: ContextHubStats }) {
+  const cachedTokens = stats.providerCachedTokens
+  const inputTokens = stats.providerInputTokens
+  const hitPercent = cachedTokens !== undefined && inputTokens !== undefined && inputTokens > 0
+    ? Math.min(100, Math.round((cachedTokens / inputTokens) * 100))
+    : null
+
+  return (
+    <>
+      {cachedTokens !== undefined ? (
+        cachedTokens > 0 ? (
+          <div className="font-medium text-green-600 dark:text-green-400">
+            供应商已确认命中 {cachedTokens.toLocaleString()} Token
+            {hitPercent !== null ? `(输入占比 ${hitPercent}%)` : ""}
+          </div>
+        ) : (
+          <div>供应商已确认本次未命中缓存(0 Token)</div>
+        )
+      ) : stats.providerUsageReported ? (
+        <div>供应商已返回 Token 用量,但未提供缓存命中明细</div>
+      ) : stats.providerCacheEnabled ? (
+        <div>已发送稳定前缀,是否命中以供应商返回为准</div>
+      ) : null}
+      {(stats.providerCacheWriteTokens ?? 0) > 0 && (
+        <div>供应商新写入缓存 {stats.providerCacheWriteTokens?.toLocaleString()} Token</div>
+      )}
+    </>
+  )
+}
+
 export function ContextHubDetails({
   reference,
   projectPath,
@@ -136,7 +167,7 @@ export function ContextHubDetails({
         <span className="min-w-0 flex-1">
           <span className="block text-xs font-medium text-foreground">上下文中控</span>
           <span className="mt-0.5 block text-[11px] text-muted-foreground">
-            本地缓存:命中 {stats.hits.toLocaleString()},刷新 {stats.refreshed.toLocaleString()},失败 {stats.failures.toLocaleString()}
+            本轮缓存事件:命中 {stats.hits.toLocaleString()},刷新 {stats.refreshed.toLocaleString()},失败 {stats.failures.toLocaleString()}
           </span>
           <span className="mt-0.5 block text-[11px] text-muted-foreground">
             稳定核心 {stats.stableTokens.toLocaleString()} Token 会话摘要 {stats.summaryTokens.toLocaleString()} Token 动态片段 {stats.dynamicTokens.toLocaleString()} Token
@@ -150,13 +181,9 @@ export function ContextHubDetails({
       {expanded && (
         <div className="ml-8 mt-2 min-w-0">
           <div className="mb-2 space-y-0.5 text-[11px] text-muted-foreground">
-            <div>项目资料预计节省 {stats.estimatedSavedTokens.toLocaleString()} Token({stats.estimatedSavedPercent}%)</div>
+            <div>上下文压缩预计减少 {stats.estimatedSavedTokens.toLocaleString()} Token({stats.estimatedSavedPercent}%)</div>
             <div>低置信度扩展:{stats.expanded ? "已启用" : "未启用"}</div>
-            {stats.providerCachedTokens != null
-              ? <div className="font-medium text-green-600 dark:text-green-400">供应商已确认命中 {stats.providerCachedTokens.toLocaleString()} Token</div>
-              : stats.providerCacheEnabled
-                ? <div>已启用稳定前缀缓存</div>
-                : null}
+            <ProviderCacheUsage stats={stats} />
           </div>
 
           {loading ? (

+ 1 - 1
src/components/sources/outline-chat-panel.spec.tsx

@@ -148,7 +148,7 @@ describe("OutlineChatPanel controls", () => {
     const container = await renderOutlineChatPanel()
 
     expect(container.textContent).toContain("上下文中控")
-    expect(container.textContent).toContain("本地缓存:命中 3,刷新 2,失败 0")
+    expect(container.textContent).toContain("本轮缓存事件:命中 3,刷新 2,失败 0")
   })
 
   it.each([

+ 64 - 2
src/components/sources/outline-chat-panel.tsx

@@ -174,9 +174,11 @@ import {
   buildSessionContextSummary,
   flattenContextHubSystemContent,
   getContextHub,
+  persistContextHubProviderUsage,
   type ContextHubResult,
   type ContextHubSnapshotRef,
 } from "@/lib/context-hub";
+import { addLlmUsage, type LlmUsage } from "@/lib/llm-usage";
 import {
   getConversationTabTitle,
   splitConversationToolbarItems,
@@ -1865,6 +1867,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
       let hiddenToolCalls: AgentRunRecord["toolCalls"] = [];
       let followUpGenerationPrompt: string | null = null;
       let contextHubResult: ContextHubResult | null = null;
+      let providerUsage: LlmUsage | undefined;
 
       try {
         const contextHub = getContextHub(normalizePath(project.path));
@@ -2063,6 +2066,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             },
             controller.signal,
           );
+          providerUsage = addLlmUsage(providerUsage, record.usage);
           allToolCalls.push(...record.toolCalls);
           if (agentError) throw agentError;
           return { text: runText || record.finalText, record };
@@ -2335,6 +2339,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         }
 
         if (!isCurrentRun()) return { started: true, sent: false };
+        if (contextHubResult && providerUsage) {
+          try {
+            const contextHubSnapshot = await persistContextHubProviderUsage(
+              getContextHub(normalizePath(project.path)),
+              assistantId,
+              contextHubResult,
+              providerUsage,
+            );
+            if (contextHubSnapshot && isCurrentRun()) {
+              updateOutlineAssistantMessage(convId, assistantId, (message) => ({
+                ...message,
+                contextHubSnapshot,
+              }));
+            }
+          } catch (error) {
+            console.warn("AI 大纲供应商缓存用量快照保存失败,继续保留本地缓存统计:", error);
+          }
+        }
 
         const finalSources = Array.from(
           new Set([
@@ -2674,6 +2696,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
       try {
         let contextHubResult: ContextHubResult | null = null;
+        let providerUsage: LlmUsage | undefined;
         try {
           const contextHub = getContextHub(normalizePath(project.path));
           contextHubResult = await contextHub.prepare({
@@ -2773,7 +2796,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             const { agentConfig, registry: reg } = buildConfig(subAgentPlan.skillNames, true);
             let runText = "";
             let agentError: Error | null = null;
-            await new AgentRunner().run(agentConfig, reg, [
+            const record = await new AgentRunner().run(agentConfig, reg, [
               { role: "system", content: buildResumeSystemContent(["## 子 Agent 运行规则", `当前身份:${subAgentPlan.name}`, "你只能处理本 Agent 负责的维度,禁止写入文件。", "必须输出符合 AI 大纲子 Agent JSON 协议的 JSON,不要输出额外说明。"].join("\n")) },
               { role: "user", content: subAgentPlan.taskPrompt },
             ], {
@@ -2785,6 +2808,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onDone: () => {},
               onError: (error) => { agentError = error; },
             }, controller.signal);
+            providerUsage = addLlmUsage(providerUsage, record.usage);
             if (agentError) throw agentError;
             return runText;
           },
@@ -2798,7 +2822,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
             const { agentConfig, registry: reg } = buildConfig([], true);
             let mergeText = "";
             let mergeError: Error | null = null;
-            await new AgentRunner().run(agentConfig, reg, [
+            const record = await new AgentRunner().run(agentConfig, reg, [
               { role: "system", content: buildResumeSystemContent(["## 合并 Agent 运行规则", "你负责合并多个子 Agent 的结构化结果,形成最终可预览的大纲草稿。", "输出必须是用户可直接阅读和保存的大纲正文,不要输出内部调度报告。"].join("\n")) },
               { role: "user", content: ["请合并以下 AI 大纲子 Agent 结果,解决冲突并输出最终大纲草稿。", "", "## 子 Agent 结构化结果", JSON.stringify(subAgentResults, null, 2)].join("\n") },
             ], {
@@ -2813,6 +2837,7 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
               onDone: () => {},
               onError: (error) => { mergeError = error; },
             }, controller.signal);
+            providerUsage = addLlmUsage(providerUsage, record.usage);
             if (mergeError) throw mergeError;
             return mergeText || "AI大纲未返回内容。";
           },
@@ -2831,6 +2856,25 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
 
         if (!isCurrentRun()) return;
 
+        if (contextHubResult && providerUsage) {
+          try {
+            const contextHubSnapshot = await persistContextHubProviderUsage(
+              getContextHub(normalizePath(project.path)),
+              `${messageId}:${runId}`,
+              contextHubResult,
+              providerUsage,
+            );
+            if (contextHubSnapshot && isCurrentRun()) {
+              updateOutlineAssistantMessage(capturedConvId, messageId, (message) => ({
+                ...message,
+                contextHubSnapshot,
+              }));
+            }
+          } catch (error) {
+            console.warn("AI 大纲续传供应商缓存用量快照保存失败,继续保留本地缓存统计:", error);
+          }
+        }
+
         // 更新最终状态
         updateOutlineMultiAgentRun(capturedConvId, messageId, (run) => {
           if (!run) return run;
@@ -3160,6 +3204,24 @@ export function OutlineChatPanel({ onClose }: { onClose: () => void }) {
         );
         if (agentError) throw agentError;
         if (!isCurrentRun()) return;
+        if (contextHubResult && record.usage) {
+          try {
+            const updatedSnapshot = await persistContextHubProviderUsage(
+              getContextHub(normalizePath(project.path)),
+              assistantId,
+              contextHubResult,
+              record.usage,
+            );
+            if (updatedSnapshot && isCurrentRun()) {
+              updateOutlineAssistantMessage(capturedConvId, assistantId, (message) => ({
+                ...message,
+                contextHubSnapshot: updatedSnapshot,
+              }));
+            }
+          } catch (error) {
+            console.warn("AI 大纲重新生成供应商缓存用量快照保存失败,继续保留本地缓存统计:", error);
+          }
+        }
 
         const sources = [
           ...outlineToolCallsToSources(record.toolCalls),

+ 28 - 0
src/lib/agent/runner.spec.ts

@@ -111,6 +111,34 @@ describe("AgentRunner", () => {
     expect(mockStreamChat.mock.calls[0][1][0]).toEqual(cacheableSystem)
   })
 
+  it("aggregates provider usage across agent rounds", async () => {
+    let round = 0
+    mockStreamChat.mockImplementation(async (_config: unknown, _msgs: unknown[], cb: StreamCallbacks) => {
+      round += 1
+      if (round === 1) {
+        cb.onUsage?.({ inputTokens: 1000, outputTokens: 20, cachedInputTokens: 600 })
+        cb.onToolCallDelta?.({ index: 0, id: "call_1", name: "missing_tool", arguments: "{}" })
+      } else {
+        cb.onUsage?.({ inputTokens: 700, outputTokens: 80, cachedInputTokens: 500 })
+        cb.onToken("完成")
+      }
+      cb.onDone()
+    })
+
+    const result = await runner.run(
+      { maxRounds: 2, tools: [], systemPrompt: "", llmConfig: mockLlmConfig },
+      registry,
+      [systemMsg, userMsg],
+      { onText: vi.fn(), onToolCall: vi.fn(), onToolResult: vi.fn(), onToolError: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
+    )
+
+    expect(result.usage).toEqual({
+      inputTokens: 1700,
+      outputTokens: 100,
+      cachedInputTokens: 1100,
+    })
+  })
+
   it("executes tool calls and continues the loop", async () => {
     const tool: Tool = {
       name: "read_chapter",

+ 4 - 0
src/lib/agent/runner.ts

@@ -15,6 +15,7 @@ import {
 } from "./task-breakpoint"
 import type { ChatMessage } from "../llm-providers"
 import { isReasoningDisabled, isReasoningOnlyResponseError, withReasoningDisabled } from "../reasoning-retry"
+import { addLlmUsage } from "../llm-usage"
 
 export class ModelDoesNotSupportToolsError extends Error {
   constructor() {
@@ -119,6 +120,9 @@ export class AgentRunner {
         onToolCallDelta: (delta: ToolCallDelta) => {
           toolCallDeltas.push(delta)
         },
+        onUsage: (usage) => {
+          record.usage = addLlmUsage(record.usage, usage)
+        },
         onDone: () => {
           // stream finished
         },

+ 2 - 0
src/lib/agent/types.ts

@@ -1,5 +1,6 @@
 import type { LlmConfig } from "@/stores/wiki-store"
 import type { ChatMessage, RequestOverrides } from "../llm-providers"
+import type { LlmUsage } from "../llm-usage"
 
 export interface ToolParameter {
   type: "string" | "number" | "boolean" | "object" | "array" | "integer"
@@ -153,6 +154,7 @@ export interface AgentRunRecord {
   }[]
   roundsUsed: number
   finalText: string
+  usage?: LlmUsage
 }
 
 export const DEFAULT_MAX_ROUNDS = 15

+ 14 - 2
src/lib/changelog.spec.ts

@@ -6,7 +6,8 @@ describe("changelog", () => {
     const entries = allChangelog()
     const versions = entries.map((entry) => entry.version)
 
-    expect(versions.slice(0, 29)).toEqual([
+    expect(versions.slice(0, 30)).toEqual([
+      "2.2.37",
       "2.2.36",
       "2.2.35",
       "2.2.33",
@@ -37,7 +38,7 @@ describe("changelog", () => {
       "2.2.0",
       "2.1.0",
     ])
-    expect(versions[29]).toBe("2.0.0")
+    expect(versions[30]).toBe("2.0.0")
 
     for (let patch = 1; patch <= 6; patch += 1) {
       expect(versions).not.toContain(`2.2.${patch}`)
@@ -76,6 +77,17 @@ describe("changelog", () => {
     expect(zh).toContain("并发保存")
   })
 
+  it("returns the 2.2.37 cache usage release notes", () => {
+    const release = currentVersionChangelog("2.2.37")[0]
+    const zh = release.highlights.zh.join("\n")
+
+    expect(release.version).toBe("2.2.37")
+    expect(zh).toContain("供应商真实缓存")
+    expect(zh).toContain("Agent")
+    expect(zh).toContain("上下文压缩")
+    expect(zh).toContain("稳定核心")
+  })
+
   it("returns the 2.2.0 changelog entry", () => {
     const release = currentVersionChangelog("2.2.0")[0]
     const zh = release.highlights.zh.join("\n")

+ 24 - 0
src/lib/changelog.ts

@@ -7,6 +7,27 @@ export interface ChangelogEntry {
   };
 }
 
+const TWO_POINT_TWO_THIRTY_SEVEN_CHANGELOG: ChangelogEntry = {
+  version: "2.2.37",
+  date: "2026-07-15",
+  highlights: {
+    en: [
+      "AI Chat and AI Outline now read real provider cache usage from OpenAI, Anthropic, Gemini, and Responses API results.",
+      "Provider usage is aggregated across Agent tool rounds, retries, and parallel outline agents, then stored in the current context snapshot.",
+      "Cache hit, refresh, and failure values are now labeled as per-request cache events, while estimated Token savings are explicitly identified as context compression estimates.",
+      "Stable context cores now remain reusable whenever their final prefix bytes are unchanged, avoiding false refreshes caused only by unrelated source revisions.",
+      "The interface reports confirmed cache hits only when the provider returns cache details, and otherwise states that a stable prefix was sent for provider-side reuse.",
+    ],
+    zh: [
+      "AI 对话与 AI 大纲现已读取 OpenAI、Anthropic、Gemini 和 Responses API 返回的供应商真实缓存用量。",
+      "Agent 工具轮次、失败重试和大纲多 Agent 调用会统一累计供应商用量,并写入当前上下文快照。",
+      "命中、刷新和失败明确显示为本轮缓存事件;预计节省 Token 明确标注为上下文压缩预计减少。",
+      "稳定核心在最终前缀字节不变时继续复用,避免仅因无关资料修订而错误刷新。",
+      "只有供应商返回缓存明细时才显示确认命中;否则明确提示已发送稳定前缀,最终以供应商返回为准。",
+    ],
+  },
+};
+
 const TWO_POINT_TWO_THIRTY_SIX_CHANGELOG: ChangelogEntry = {
   version: "2.2.36",
   date: "2026-07-15",
@@ -844,6 +865,8 @@ export const CHANGELOG: ChangelogEntry[] = [
 ];
 
 export function currentVersionChangelog(version: string): ChangelogEntry[] {
+  if (version === TWO_POINT_TWO_THIRTY_SEVEN_CHANGELOG.version)
+    return [TWO_POINT_TWO_THIRTY_SEVEN_CHANGELOG];
   if (version === TWO_POINT_TWO_THIRTY_SIX_CHANGELOG.version)
     return [TWO_POINT_TWO_THIRTY_SIX_CHANGELOG];
   if (version === TWO_POINT_TWO_THIRTY_FIVE_CHANGELOG.version)
@@ -913,6 +936,7 @@ export function currentVersionChangelog(version: string): ChangelogEntry[] {
 
 export function allChangelog(): ChangelogEntry[] {
   return [
+    TWO_POINT_TWO_THIRTY_SEVEN_CHANGELOG,
     TWO_POINT_TWO_THIRTY_SIX_CHANGELOG,
     TWO_POINT_TWO_THIRTY_FIVE_CHANGELOG,
     TWO_POINT_TWO_THIRTY_THREE_CHANGELOG,

+ 2 - 0
src/lib/context-hub/ai-chat-integration.spec.ts

@@ -26,6 +26,8 @@ describe("AI chat context hub integration", () => {
 
   it("persists a snapshot reference on the target assistant message", () => {
     expect(source).toContain("await contextHub.saveSnapshot(assistantMessage.id, contextHubResult)")
+    expect(source).toContain("persistContextHubProviderUsage(")
+    expect(source).toContain("record.usage")
     expect(source).toContain("contextHubSnapshot")
   })
 

+ 2 - 0
src/lib/context-hub/ai-outline-integration.spec.ts

@@ -26,6 +26,8 @@ describe("AI outline context hub integration", () => {
 
   it("persists snapshots for main send, resume, and regeneration", () => {
     expect(source.match(/\.saveSnapshot\(/g)).toHaveLength(3)
+    expect(source.match(/persistContextHubProviderUsage\(/g)).toHaveLength(3)
+    expect(source).toContain("addLlmUsage(")
     expect(source).toContain("contextHubSnapshot")
     expect(source).toContain("<ContextHubDetails")
     expect(source).not.toContain("formatContextHubStatsForDetails")

+ 27 - 0
src/lib/context-hub/composer.spec.ts

@@ -90,4 +90,31 @@ describe("composeContext", () => {
 
     expect(result.stats.estimatedSavedPercent).toBeGreaterThanOrEqual(30)
   })
+
+  it("compares trimmed context with the same summary and references", () => {
+    const input = {
+      contextPack: pack({
+        recentChapterContents: ["章节原文".repeat(1000)],
+        searchResults: "低优先级检索".repeat(100),
+      }),
+      dependencies: {},
+      confidence: 0.9,
+      tokenBudget: 100_000,
+    }
+    const base = composeContext(input)
+    const supplemented = composeContext({
+      ...input,
+      sessionSummary: "会话摘要".repeat(100),
+      referenceContext: ["显式引用".repeat(100)],
+    })
+    const baseComposed = base.stats.stableTokens + base.stats.summaryTokens + base.stats.dynamicTokens
+    const supplementedComposed = supplemented.stats.stableTokens
+      + supplemented.stats.summaryTokens
+      + supplemented.stats.dynamicTokens
+
+    expect(base.stats.estimatedSavedTokens).toBeGreaterThan(0)
+    expect(supplemented.stats.estimatedSavedTokens).toBe(base.stats.estimatedSavedTokens)
+    expect(supplemented.stats.candidateTokens - base.stats.candidateTokens)
+      .toBe(supplementedComposed - baseComposed)
+  })
 })

+ 11 - 5
src/lib/context-hub/composer.ts

@@ -46,15 +46,18 @@ function stableFragments(pack: ContextPack): ContextFragment[] {
   ]
 }
 
-function dynamicFragments(input: ComposeContextInput, expanded: boolean): ContextFragment[] {
-  const pack = input.contextPack
-  const references = (input.referenceContext ?? []).map((value, index) => ({
+function referenceFragments(input: ComposeContextInput): ContextFragment[] {
+  return (input.referenceContext ?? []).map((value, index) => ({
     title: `显式引用 ${index + 1}`,
     text: value,
     required: true,
   }))
+}
+
+function dynamicFragments(input: ComposeContextInput, expanded: boolean): ContextFragment[] {
+  const pack = input.contextPack
   const required: ContextFragment[] = [
-    ...references,
+    ...referenceFragments(input),
     { title: "本轮任务", text: pack.task, required: true },
     { title: "章节目标", text: pack.chapterGoal, required: true },
     { title: "必须做到", text: pack.mustDo, required: true },
@@ -108,11 +111,14 @@ export function composeContext(input: ComposeContextInput): ComposedContext {
   const stableTokens = estimateContextTokens(stableCore)
   const summaryTokens = estimateContextTokens(sessionSummary)
   const availableDynamicTokens = Math.max(0, tokenBudget - stableTokens - summaryTokens)
+  const dynamicFragmentsForRequest = dynamicFragments(input, expanded)
   const dynamicContext = joinSections(
-    applyBudget(dynamicFragments(input, expanded), availableDynamicTokens),
+    applyBudget(dynamicFragmentsForRequest, availableDynamicTokens),
   )
   const dynamicTokens = estimateContextTokens(dynamicContext)
   const candidateTokens = estimateContextTokens(contextPackToPrompt(input.contextPack))
+    + summaryTokens
+    + estimateContextTokens(joinSections(referenceFragments(input)))
   const composedTokens = stableTokens + summaryTokens + dynamicTokens
   const estimatedSavedTokens = Math.max(0, candidateTokens - composedTokens)
   const estimatedSavedPercent = candidateTokens > 0

+ 40 - 0
src/lib/context-hub/context-hub.spec.ts

@@ -140,6 +140,46 @@ describe("ContextHubController", () => {
     ])
   })
 
+  it("keeps the stable core cached when only an unrelated chapter changes", async () => {
+    const harness = createHarness()
+    let chapterRevision = 1
+    harness.registry.getDependencies.mockImplementation((kinds?: string[]) => (
+      kinds
+        ? { "E:/Novel/wiki/outlines/main.md": 1 }
+        : {
+            "E:/Novel/wiki/outlines/main.md": 1,
+            "E:/Novel/wiki/chapters/chapter-1.md": chapterRevision,
+          }
+    ))
+
+    await harness.controller.prepare(request)
+    chapterRevision = 2
+    const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
+
+    expect(second?.cacheItems).toContainEqual(expect.objectContaining({
+      sourceName: "stableCore",
+      status: "hit",
+      dependencyPaths: ["wiki/outlines/main.md"],
+    }))
+  })
+
+  it("keeps the stable core cached when source revisions change but its bytes stay identical", async () => {
+    const harness = createHarness()
+    let outlineRevision = 1
+    harness.registry.getDependencies.mockImplementation(() => ({
+      "E:/Novel/wiki/outlines/main.md": outlineRevision,
+    }))
+
+    await harness.controller.prepare(request)
+    outlineRevision = 2
+    const second = await harness.controller.prepare({ ...request, task: "继续生成大纲" })
+
+    expect(second?.cacheItems).toContainEqual(expect.objectContaining({
+      sourceName: "stableCore",
+      status: "hit",
+    }))
+  })
+
   it("removes a Windows project root from dependency paths case-insensitively", async () => {
     const harness = createHarness()
     harness.registry.getDependencies.mockReturnValue({

+ 7 - 14
src/lib/context-hub/context-hub.ts

@@ -50,6 +50,8 @@ type BuildContextPack = (
   options?: { categories?: DataSourceCategory[]; loadAdapter?: DataSourceLoadAdapter },
 ) => Promise<ContextPack>
 
+const STABLE_SOURCE_KINDS: ContextSourceKind[] = ["soul", "setting", "entity", "outline"]
+
 export interface ContextHubControllerDependencies {
   registry?: HubRegistry
   storage?: HubStorage
@@ -58,15 +60,6 @@ export interface ContextHubControllerDependencies {
   subscribe?: (listener: (event: ProjectFileMutation) => void) => () => void
 }
 
-function dependenciesMatch(
-  left: Record<string, number>,
-  right: Record<string, number>,
-): boolean {
-  const entries = Object.entries(left)
-  return entries.length === Object.keys(right).length
-    && entries.every(([path, revision]) => right[path] === revision)
-}
-
 function confidenceFor(request: ContextHubRequest, pack: ContextPack): number {
   if ((request.references?.length ?? 0) > 0) return 0.95
   if (request.chapterNumber && !pack.chapterGoal.trim() && !pack.outline.trim()) return 0.45
@@ -215,6 +208,7 @@ export class ContextHubController implements ContextHub {
     const refresh = await this.registry.refresh()
     for (const path of refresh.changedPaths) this.fileCache.delete(normalizeContextPath(path))
     const dependencies = this.registry.getDependencies()
+    const stableDependencies = this.registry.getDependencies(STABLE_SOURCE_KINDS)
     const warnings: string[] = []
     const cacheAdapter = new DataSourceCacheAdapter({
       registry: this.registry,
@@ -253,21 +247,20 @@ export class ContextHubController implements ContextHub {
       if (
         existing
         && existing.text === composed.stableCore
-        && dependenciesMatch(existing.dependencies, dependencies)
       ) {
         stableHits = 1
         cacheItems.push({
           key: `stable-core:${request.surface}`,
           sourceName: "stableCore",
           status: "hit",
-          dependencyPaths: Object.keys(dependencies),
+          dependencyPaths: Object.keys(stableDependencies),
         })
       } else {
         await this.storage.writeStableBundle(request.surface, {
           schemaVersion: CONTEXT_CACHE_SCHEMA_VERSION,
           surface: request.surface,
           text: composed.stableCore,
-          dependencies,
+          dependencies: stableDependencies,
           updatedAt: Date.now(),
         })
         stableRefreshes = 1
@@ -275,7 +268,7 @@ export class ContextHubController implements ContextHub {
           key: `stable-core:${request.surface}`,
           sourceName: "stableCore",
           status: "refreshed",
-          dependencyPaths: Object.keys(dependencies),
+          dependencyPaths: Object.keys(stableDependencies),
         })
       }
     } catch {
@@ -284,7 +277,7 @@ export class ContextHubController implements ContextHub {
         key: `stable-core:${request.surface}`,
         sourceName: "stableCore",
         status: "failed",
-        dependencyPaths: Object.keys(dependencies),
+        dependencyPaths: Object.keys(stableDependencies),
       })
       warnings.push("稳定上下文缓存写入失败,本轮已继续使用内存中的最新内容。")
     }

+ 1 - 0
src/lib/context-hub/index.ts

@@ -6,6 +6,7 @@ export {
   selectContextHistoryMessages,
 } from "./session-summary"
 export { buildContextHubSystemContent, flattenContextHubSystemContent } from "./prompt-content"
+export { applyProviderUsageToStats, persistContextHubProviderUsage } from "./provider-usage"
 export type {
   ContextHub,
   ContextHubRequest,

+ 59 - 0
src/lib/context-hub/provider-usage.spec.ts

@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from "vitest"
+import type { ContextHubResult, ContextHubSnapshotRef, ContextHubStats } from "./types"
+import { applyProviderUsageToStats, persistContextHubProviderUsage } from "./provider-usage"
+
+const baseStats: ContextHubStats = {
+  hits: 2,
+  refreshed: 1,
+  failures: 0,
+  stableTokens: 1000,
+  summaryTokens: 100,
+  dynamicTokens: 300,
+  candidateTokens: 2000,
+  estimatedSavedTokens: 600,
+  estimatedSavedPercent: 30,
+  expanded: false,
+  providerCacheEnabled: true,
+}
+
+describe("context hub provider usage", () => {
+  it("stores confirmed cache usage without changing local cache counters", () => {
+    expect(applyProviderUsageToStats(baseStats, {
+      inputTokens: 1600,
+      outputTokens: 200,
+      cachedInputTokens: 800,
+      cacheWriteInputTokens: 300,
+    })).toEqual({
+      ...baseStats,
+      providerUsageReported: true,
+      providerInputTokens: 1600,
+      providerCachedTokens: 800,
+      providerCacheWriteTokens: 300,
+    })
+  })
+
+  it("updates the persisted snapshot after the model response", async () => {
+    const reference: ContextHubSnapshotRef = {
+      id: "assistant:1",
+      surface: "ai-chat",
+      createdAt: 20,
+      stats: baseStats,
+    }
+    const saveSnapshot = vi.fn(async () => reference)
+    const result = { stats: { ...baseStats } } as ContextHubResult
+
+    await expect(persistContextHubProviderUsage(
+      { saveSnapshot },
+      "assistant:1",
+      result,
+      { inputTokens: 1600, cachedInputTokens: 800 },
+    )).resolves.toBe(reference)
+
+    expect(result.stats).toMatchObject({
+      providerUsageReported: true,
+      providerInputTokens: 1600,
+      providerCachedTokens: 800,
+    })
+    expect(saveSnapshot).toHaveBeenCalledWith("assistant:1", result)
+  })
+})

+ 33 - 0
src/lib/context-hub/provider-usage.ts

@@ -0,0 +1,33 @@
+import type { LlmUsage } from "@/lib/llm-usage"
+import type {
+  ContextHub,
+  ContextHubResult,
+  ContextHubSnapshotRef,
+  ContextHubStats,
+} from "./types"
+
+export function applyProviderUsageToStats(
+  stats: ContextHubStats,
+  usage: LlmUsage,
+): ContextHubStats {
+  return {
+    ...stats,
+    providerUsageReported: true,
+    ...(usage.inputTokens !== undefined ? { providerInputTokens: usage.inputTokens } : {}),
+    ...(usage.cachedInputTokens !== undefined ? { providerCachedTokens: usage.cachedInputTokens } : {}),
+    ...(usage.cacheWriteInputTokens !== undefined
+      ? { providerCacheWriteTokens: usage.cacheWriteInputTokens }
+      : {}),
+  }
+}
+
+export async function persistContextHubProviderUsage(
+  contextHub: Pick<ContextHub, "saveSnapshot">,
+  snapshotId: string,
+  result: ContextHubResult,
+  usage: LlmUsage | undefined,
+): Promise<ContextHubSnapshotRef | null> {
+  if (!usage) return null
+  result.stats = applyProviderUsageToStats(result.stats, usage)
+  return contextHub.saveSnapshot(snapshotId, result)
+}

+ 3 - 0
src/lib/context-hub/types.ts

@@ -67,7 +67,10 @@ export interface ContextHubStats {
   estimatedSavedPercent: number
   expanded: boolean
   providerCacheEnabled: boolean
+  providerUsageReported?: boolean
+  providerInputTokens?: number
   providerCachedTokens?: number
+  providerCacheWriteTokens?: number
 }
 
 export type ContextCacheItemStatus = "hit" | "refreshed" | "failed"

+ 13 - 0
src/lib/llm-client.ts

@@ -5,15 +5,18 @@ import { getHttpFetch, isFetchNetworkError } from "./tauri-fetch"
 import { countReasoningCharsInLine, extractReasoningTextFromLine } from "./reasoning-detector"
 import { resolveRuntimeLocalCliConfig } from "./local-cli-config"
 import { trimChatMessagesToBudget } from "./chat-request-budget"
+import { mergeLlmUsageSnapshot, type LlmUsage } from "./llm-usage"
 
 export type { ChatMessage, RequestOverrides } from "./llm-providers"
 export { isFetchNetworkError } from "./tauri-fetch"
+export type { LlmUsage } from "./llm-usage"
 
 export interface StreamCallbacks {
   onToken: (token: string) => void
   onReasoningToken?: (token: string) => void
   /** 工具调用流式 delta,用于累积 tool_calls */
   onToolCallDelta?: (delta: { index: number; id?: string; name?: string; arguments?: string }) => void
+  onUsage?: (usage: LlmUsage) => void
   onDone: () => void
   onError: (error: Error) => void
 }
@@ -328,6 +331,12 @@ export async function streamChat(
 
     const reader = response.body.getReader()
     let lineBuffer = ""
+    let streamUsage: LlmUsage | undefined
+
+    const recordUsage = (line: string) => {
+      const usage = providerConfig.parseUsage(line)
+      if (usage) streamUsage = mergeLlmUsageSnapshot(streamUsage, usage)
+    }
 
     // Diagnostic counters. Some OpenAI-compatible endpoints stream
     // chain-of-thought through a `reasoning_content` (DeepSeek-R1,
@@ -360,6 +369,7 @@ export async function streamChat(
         if (done) {
           if (lineBuffer.trim()) {
             const trimmed = lineBuffer.trim()
+            recordUsage(trimmed)
             const toolDelta = parseToolCallDeltaFromLine(trimmed)
             if (toolDelta) {
               callbacks.onToolCallDelta?.(toolDelta)
@@ -379,6 +389,7 @@ export async function streamChat(
         for (const line of lines) {
           const trimmed = line.trim()
           if (!trimmed) continue
+          recordUsage(trimmed)
           const toolDelta = parseToolCallDeltaFromLine(trimmed)
           if (toolDelta) {
             callbacks.onToolCallDelta?.(toolDelta)
@@ -391,6 +402,8 @@ export async function streamChat(
         }
       }
 
+      if (streamUsage) callbacks.onUsage?.(streamUsage)
+
       // Stream ended cleanly. If the model produced thinking tokens
       // but no actual answer, surface that as a clear diagnostic
       // instead of letting the caller silently see "" (which usually

+ 72 - 0
src/lib/llm-client.usage.spec.ts

@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { streamChat } from "./llm-client"
+
+const mocks = vi.hoisted(() => ({
+  fetch: vi.fn(),
+}))
+
+vi.mock("./tauri-fetch", () => ({
+  getHttpFetch: vi.fn(async () => mocks.fetch),
+  isFetchNetworkError: vi.fn(() => false),
+}))
+
+vi.mock("./local-cli-config", () => ({
+  resolveRuntimeLocalCliConfig: vi.fn(async (config: LlmConfig) => config),
+}))
+
+const config: LlmConfig = {
+  provider: "openai",
+  apiKey: "sk-test",
+  model: "gpt-test",
+  ollamaUrl: "",
+  customEndpoint: "",
+  maxContextSize: 128_000,
+}
+
+describe("streamChat usage", () => {
+  beforeEach(() => {
+    mocks.fetch.mockReset()
+  })
+
+  it("requests and emits OpenAI stream usage once", async () => {
+    const encoder = new TextEncoder()
+    const body = new ReadableStream<Uint8Array>({
+      start(controller) {
+        controller.enqueue(encoder.encode([
+          'data: {"choices":[{"delta":{"content":"完成"}}]}',
+          'data: {"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":80,"total_tokens":1280,"prompt_tokens_details":{"cached_tokens":1024}}}',
+          "data: [DONE]",
+          "",
+        ].join("\n")))
+        controller.close()
+      },
+    })
+    mocks.fetch.mockResolvedValue(new Response(body, { status: 200 }))
+    const onUsage = vi.fn()
+    const onDone = vi.fn()
+    const onError = vi.fn()
+
+    await streamChat(config, [{ role: "user", content: "测试" }], {
+      onToken: vi.fn(),
+      onUsage,
+      onDone,
+      onError,
+    })
+
+    const request = mocks.fetch.mock.calls[0][1] as RequestInit
+    expect(JSON.parse(String(request.body))).toMatchObject({
+      stream: true,
+      stream_options: { include_usage: true },
+    })
+    expect(onUsage).toHaveBeenCalledOnce()
+    expect(onUsage).toHaveBeenCalledWith({
+      inputTokens: 1200,
+      outputTokens: 80,
+      totalTokens: 1280,
+      cachedInputTokens: 1024,
+    })
+    expect(onDone).toHaveBeenCalledOnce()
+    expect(onError).not.toHaveBeenCalled()
+  })
+})

+ 144 - 0
src/lib/llm-providers.ts

@@ -5,6 +5,7 @@ import {
   isAzureOpenAiEndpoint,
 } from "@/lib/azure-openai"
 import { normalizeEndpoint } from "@/lib/endpoint-normalizer"
+import type { LlmUsage } from "./llm-usage"
 
 /**
  * One piece of a multimodal message body. Text + image is the only
@@ -77,6 +78,7 @@ interface ProviderConfig {
   headers: Record<string, string>
   buildBody: (messages: ChatMessage[], overrides?: RequestOverrides) => unknown
   parseStream: (line: string) => string | null
+  parseUsage: (line: string) => LlmUsage | null
 }
 
 const JSON_CONTENT_TYPE = "application/json"
@@ -181,6 +183,52 @@ function parseOpenAiLine(line: string): string | null {
   }
 }
 
+function tokenCount(value: unknown): number | undefined {
+  return typeof value === "number" && Number.isFinite(value) && value >= 0
+    ? Math.floor(value)
+    : undefined
+}
+
+function usageOrNull(usage: LlmUsage): LlmUsage | null {
+  return Object.values(usage).some((value) => value !== undefined) ? usage : null
+}
+
+function parseOpenAiUsage(line: string): LlmUsage | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  if (data === "[DONE]") return null
+  try {
+    const parsed = JSON.parse(data) as {
+      usage?: {
+        prompt_tokens?: number
+        completion_tokens?: number
+        total_tokens?: number
+        cached_tokens?: number
+        prompt_cache_hit_tokens?: number
+        cache_read_input_tokens?: number
+        cache_creation_input_tokens?: number
+        prompt_tokens_details?: { cached_tokens?: number }
+        input_tokens_details?: { cached_tokens?: number }
+      }
+    }
+    const raw = parsed.usage
+    if (!raw) return null
+    return usageOrNull({
+      inputTokens: tokenCount(raw.prompt_tokens),
+      outputTokens: tokenCount(raw.completion_tokens),
+      totalTokens: tokenCount(raw.total_tokens),
+      cachedInputTokens: tokenCount(raw.prompt_tokens_details?.cached_tokens)
+        ?? tokenCount(raw.input_tokens_details?.cached_tokens)
+        ?? tokenCount(raw.cached_tokens)
+        ?? tokenCount(raw.prompt_cache_hit_tokens)
+        ?? tokenCount(raw.cache_read_input_tokens),
+      cacheWriteInputTokens: tokenCount(raw.cache_creation_input_tokens),
+    })
+  } catch {
+    return null
+  }
+}
+
 function parseResponsesLine(line: string): string | null {
   if (!line.startsWith("data: ")) return null
   const data = line.slice(6).trim()
@@ -196,6 +244,34 @@ function parseResponsesLine(line: string): string | null {
   }
 }
 
+function parseResponsesUsage(line: string): LlmUsage | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  if (data === "[DONE]") return null
+  try {
+    const parsed = JSON.parse(data) as {
+      response?: {
+        usage?: {
+          input_tokens?: number
+          output_tokens?: number
+          total_tokens?: number
+          input_tokens_details?: { cached_tokens?: number }
+        }
+      }
+    }
+    const raw = parsed.response?.usage
+    if (!raw) return null
+    return usageOrNull({
+      inputTokens: tokenCount(raw.input_tokens),
+      outputTokens: tokenCount(raw.output_tokens),
+      totalTokens: tokenCount(raw.total_tokens),
+      cachedInputTokens: tokenCount(raw.input_tokens_details?.cached_tokens),
+    })
+  } catch {
+    return null
+  }
+}
+
 function parseAnthropicLine(line: string): string | null {
   if (!line.startsWith("data: ")) return null
   const data = line.slice(6).trim()
@@ -216,6 +292,33 @@ function parseAnthropicLine(line: string): string | null {
   }
 }
 
+function parseAnthropicUsage(line: string): LlmUsage | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  try {
+    const parsed = JSON.parse(data) as {
+      message?: { usage?: Record<string, unknown> }
+      usage?: Record<string, unknown>
+    }
+    const raw = parsed.message?.usage ?? parsed.usage
+    if (!raw) return null
+    const directInput = tokenCount(raw.input_tokens)
+    const cacheRead = tokenCount(raw.cache_read_input_tokens)
+    const cacheWrite = tokenCount(raw.cache_creation_input_tokens)
+    const hasInput = directInput !== undefined || cacheRead !== undefined || cacheWrite !== undefined
+    return usageOrNull({
+      inputTokens: hasInput
+        ? (directInput ?? 0) + (cacheRead ?? 0) + (cacheWrite ?? 0)
+        : undefined,
+      outputTokens: tokenCount(raw.output_tokens),
+      cachedInputTokens: cacheRead,
+      cacheWriteInputTokens: cacheWrite,
+    })
+  } catch {
+    return null
+  }
+}
+
 export function parseGoogleLine(line: string): string | null {
   if (!line.startsWith("data: ")) return null
   const data = line.slice(6).trim()
@@ -245,6 +348,31 @@ export function parseGoogleLine(line: string): string | null {
   }
 }
 
+function parseGoogleUsage(line: string): LlmUsage | null {
+  if (!line.startsWith("data: ")) return null
+  const data = line.slice(6).trim()
+  try {
+    const parsed = JSON.parse(data) as {
+      usageMetadata?: {
+        promptTokenCount?: number
+        candidatesTokenCount?: number
+        totalTokenCount?: number
+        cachedContentTokenCount?: number
+      }
+    }
+    const raw = parsed.usageMetadata
+    if (!raw) return null
+    return usageOrNull({
+      inputTokens: tokenCount(raw.promptTokenCount),
+      outputTokens: tokenCount(raw.candidatesTokenCount),
+      totalTokens: tokenCount(raw.totalTokenCount),
+      cachedInputTokens: tokenCount(raw.cachedContentTokenCount),
+    })
+  } catch {
+    return null
+  }
+}
+
 /**
  * Translate a `ChatMessage.content` into the OpenAI Chat Completions
  * `content` field. The wire accepts either a plain string or an
@@ -420,6 +548,13 @@ function buildOpenAiCompatibleBody(
 ): Record<string, unknown> {
   const reasoning = effectiveReasoning(config, overrides)
   const body: Record<string, unknown> = buildOpenAiBody(messages, stripWireAgnosticOverrides(overrides))
+  if (
+    config.provider === "openai"
+    || config.provider === "azure"
+    || (config.provider === "custom" && isAzureOpenAiEndpoint(config.customEndpoint))
+  ) {
+    body.stream_options = { include_usage: true }
+  }
   adaptOpenAiStrictCompletionBody(config, body)
   adaptKimiBody(config, body)
 
@@ -745,6 +880,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           model,
         }),
         parseStream: parseOpenAiLine,
+        parseUsage: parseOpenAiUsage,
       }
 
     case "anthropic": {
@@ -757,6 +893,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           model,
         }),
         parseStream: parseAnthropicLine,
+        parseUsage: parseAnthropicUsage,
       }
     }
 
@@ -777,6 +914,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           reasoning: effectiveReasoning(config, overrides),
         }),
         parseStream: parseGoogleLine,
+        parseUsage: parseGoogleUsage,
       }
     }
 
@@ -794,6 +932,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
         buildBody: (messages, overrides) =>
           buildOpenAiCompatibleBody(config, messages, overrides),
         parseStream: parseOpenAiLine,
+        parseUsage: parseOpenAiUsage,
       }
     }
 
@@ -819,6 +958,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           model,
         }),
         parseStream: parseOpenAiLine,
+        parseUsage: parseOpenAiUsage,
       }
     }
 
@@ -837,6 +977,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           model,
         }),
         parseStream: parseAnthropicLine,
+        parseUsage: parseAnthropicUsage,
       }
     }
 
@@ -866,6 +1007,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
             model,
           }),
           parseStream: parseAnthropicLine,
+          parseUsage: parseAnthropicUsage,
         }
       }
       if (mode === "responses") {
@@ -878,6 +1020,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           headers: getCustomCompatibleHeaders(apiKey, url),
           buildBody: (messages, overrides) => buildResponsesBody(config, messages, overrides),
           parseStream: parseResponsesLine,
+          parseUsage: parseResponsesUsage,
         }
       }
       // Defense-in-depth: settings-side EndpointField normalizes URLs on
@@ -912,6 +1055,7 @@ export function getProviderConfig(config: LlmConfig): ProviderConfig {
           return body
         },
         parseStream: parseOpenAiLine,
+        parseUsage: parseOpenAiUsage,
       }
     }
 

+ 89 - 0
src/lib/llm-usage.spec.ts

@@ -0,0 +1,89 @@
+import { describe, expect, it } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
+import { getProviderConfig } from "./llm-providers"
+import { addLlmUsage, mergeLlmUsageSnapshot } from "./llm-usage"
+
+function config(provider: LlmConfig["provider"], overrides: Partial<LlmConfig> = {}): LlmConfig {
+  return {
+    provider,
+    apiKey: "sk-test",
+    model: "test-model",
+    ollamaUrl: "http://localhost:11434",
+    customEndpoint: "https://example.test/v1",
+    maxContextSize: 128_000,
+    ...overrides,
+  }
+}
+
+describe("provider token usage parsing", () => {
+  it("parses OpenAI cached prompt tokens", () => {
+    const usage = getProviderConfig(config("openai")).parseUsage(
+      'data: {"choices":[],"usage":{"prompt_tokens":1200,"completion_tokens":80,"total_tokens":1280,"prompt_tokens_details":{"cached_tokens":1024}}}',
+    )
+
+    expect(usage).toEqual({
+      inputTokens: 1200,
+      outputTokens: 80,
+      totalTokens: 1280,
+      cachedInputTokens: 1024,
+    })
+  })
+
+  it("parses Anthropic cache reads and cache writes into total input", () => {
+    const usage = getProviderConfig(config("anthropic")).parseUsage(
+      'data: {"type":"message_start","message":{"usage":{"input_tokens":200,"cache_creation_input_tokens":300,"cache_read_input_tokens":500}}}',
+    )
+
+    expect(usage).toEqual({
+      inputTokens: 1000,
+      cachedInputTokens: 500,
+      cacheWriteInputTokens: 300,
+    })
+  })
+
+  it("parses Gemini cached content usage", () => {
+    const usage = getProviderConfig(config("google")).parseUsage(
+      'data: {"candidates":[],"usageMetadata":{"promptTokenCount":900,"candidatesTokenCount":100,"totalTokenCount":1000,"cachedContentTokenCount":600}}',
+    )
+
+    expect(usage).toEqual({
+      inputTokens: 900,
+      outputTokens: 100,
+      totalTokens: 1000,
+      cachedInputTokens: 600,
+    })
+  })
+
+  it("parses Responses API cached input tokens", () => {
+    const usage = getProviderConfig(config("custom", { apiMode: "responses" })).parseUsage(
+      'data: {"type":"response.completed","response":{"usage":{"input_tokens":1100,"output_tokens":100,"total_tokens":1200,"input_tokens_details":{"cached_tokens":800}}}}',
+    )
+
+    expect(usage).toEqual({
+      inputTokens: 1100,
+      outputTokens: 100,
+      totalTokens: 1200,
+      cachedInputTokens: 800,
+    })
+  })
+})
+
+describe("LLM usage aggregation", () => {
+  it("keeps the largest cumulative snapshot within one stream", () => {
+    const usage = mergeLlmUsageSnapshot(
+      { inputTokens: 800, outputTokens: 20, cachedInputTokens: 400 },
+      { inputTokens: 800, outputTokens: 60, cachedInputTokens: 400 },
+    )
+
+    expect(usage).toEqual({ inputTokens: 800, outputTokens: 60, cachedInputTokens: 400 })
+  })
+
+  it("adds separate billable requests without inventing missing cache details", () => {
+    const usage = addLlmUsage(
+      { inputTokens: 800, outputTokens: 60, cachedInputTokens: 400 },
+      { inputTokens: 500, outputTokens: 40 },
+    )
+
+    expect(usage).toEqual({ inputTokens: 1300, outputTokens: 100, cachedInputTokens: 400 })
+  })
+})

+ 49 - 0
src/lib/llm-usage.ts

@@ -0,0 +1,49 @@
+export interface LlmUsage {
+  inputTokens?: number
+  outputTokens?: number
+  totalTokens?: number
+  cachedInputTokens?: number
+  cacheWriteInputTokens?: number
+}
+
+const USAGE_FIELDS = [
+  "inputTokens",
+  "outputTokens",
+  "totalTokens",
+  "cachedInputTokens",
+  "cacheWriteInputTokens",
+] as const satisfies ReadonlyArray<keyof LlmUsage>
+
+function combineLlmUsage(
+  left: LlmUsage | undefined,
+  right: LlmUsage | undefined,
+  combine: (leftValue: number, rightValue: number) => number,
+): LlmUsage | undefined {
+  if (!left) return right ? { ...right } : undefined
+  if (!right) return { ...left }
+
+  const result: LlmUsage = {}
+  for (const field of USAGE_FIELDS) {
+    const leftValue = left[field]
+    const rightValue = right[field]
+    if (leftValue === undefined && rightValue === undefined) continue
+    if (leftValue === undefined) result[field] = rightValue
+    else if (rightValue === undefined) result[field] = leftValue
+    else result[field] = combine(leftValue, rightValue)
+  }
+  return result
+}
+
+export function mergeLlmUsageSnapshot(
+  current: LlmUsage | undefined,
+  next: LlmUsage | undefined,
+): LlmUsage | undefined {
+  return combineLlmUsage(current, next, Math.max)
+}
+
+export function addLlmUsage(
+  current: LlmUsage | undefined,
+  next: LlmUsage | undefined,
+): LlmUsage | undefined {
+  return combineLlmUsage(current, next, (left, right) => left + right)
+}