Pārlūkot izejas kodu

fix(logs): support top-level flat format for cache 5m/1h tokens

- Add extraction for cache_creation_5m_input_tokens and cache_creation_1h_input_tokens at usage root level
- Priority order: nested (cache_creation.ephemeral_*) > flat top-level > old relay format
- Add 29 unit tests covering all formats, priority rules, and edge cases
- Refactor billing/performance sections to side-by-side layout in request details dialog

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
ding113 1 mēnesi atpakaļ
vecāks
revīzija
8049f57e34

+ 155 - 134
src/app/[locale]/dashboard/logs/_components/error-details-dialog.tsx

@@ -386,146 +386,167 @@ export function ErrorDetailsDialog({
             </div>
           )}
 
-          {/* 计费详情 */}
-          {costUsd && (
-            <div className="space-y-2">
-              <h4 className="font-semibold text-sm flex items-center gap-2">
-                <DollarSign className="h-4 w-4 text-green-600" />
-                {t("logs.details.billingDetails.title")}
-              </h4>
-              <div className="rounded-md border bg-muted/50 p-4">
-                <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
-                  <div className="flex justify-between">
-                    <span className="text-muted-foreground">{t("logs.billingDetails.input")}:</span>
-                    <span className="font-mono">{formatTokenAmount(inputTokens)} tokens</span>
-                  </div>
-                  <div className="flex justify-between">
-                    <span className="text-muted-foreground">
-                      {t("logs.billingDetails.output")}:
-                    </span>
-                    <span className="font-mono">{formatTokenAmount(outputTokens)} tokens</span>
-                  </div>
-                  {(cacheCreation5mInputTokens ?? 0) > 0 && (
-                    <div className="flex justify-between">
-                      <span className="text-muted-foreground">
-                        {t("logs.billingDetails.cacheWrite5m")}:
-                      </span>
-                      <span className="font-mono">
-                        {formatTokenAmount(cacheCreation5mInputTokens)} tokens{" "}
-                        <span className="text-orange-600">(1.25x)</span>
-                      </span>
-                    </div>
-                  )}
-                  {(cacheCreation1hInputTokens ?? 0) > 0 && (
-                    <div className="flex justify-between">
-                      <span className="text-muted-foreground">
-                        {t("logs.billingDetails.cacheWrite1h")}:
-                      </span>
-                      <span className="font-mono">
-                        {formatTokenAmount(cacheCreation1hInputTokens)} tokens{" "}
-                        <span className="text-orange-600">(2x)</span>
-                      </span>
-                    </div>
-                  )}
-                  {(cacheReadInputTokens ?? 0) > 0 && (
-                    <div className="flex justify-between">
-                      <span className="text-muted-foreground">
-                        {t("logs.billingDetails.cacheRead")}:
-                      </span>
-                      <span className="font-mono">
-                        {formatTokenAmount(cacheReadInputTokens)} tokens{" "}
-                        <span className="text-green-600">(0.1x)</span>
-                      </span>
-                    </div>
-                  )}
-                  {cacheTtlApplied && (
-                    <div className="flex justify-between">
-                      <span className="text-muted-foreground">
-                        {t("logs.billingDetails.cacheTtl")}:
-                      </span>
-                      <Badge variant="outline" className="text-xs">
-                        {cacheTtlApplied}
-                      </Badge>
-                    </div>
-                  )}
-                  {context1mApplied && (
-                    <div className="flex justify-between col-span-2">
-                      <span className="text-muted-foreground">
-                        {t("logs.billingDetails.context1m")}:
-                      </span>
-                      <div className="flex items-center gap-2">
-                        <Badge
-                          variant="outline"
-                          className="text-xs bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/30 dark:text-purple-300 dark:border-purple-800"
-                        >
-                          1M Context
-                        </Badge>
-                        <span className="text-xs text-muted-foreground">
-                          ({t("logs.billingDetails.context1mPricing")})
+          {/* 计费详情 + 性能数据并排布局 */}
+          {(() => {
+            const showBilling = !!costUsd;
+            const showPerformance = durationMs != null || ttfbMs != null || (outputTokens ?? 0) > 0;
+            const showBothSections = showBilling && showPerformance;
+            return (
+              <div
+                className={cn(
+                  "grid gap-4",
+                  showBothSections ? "grid-cols-1 md:grid-cols-2" : "grid-cols-1"
+                )}
+              >
+                {/* 计费详情 */}
+                {costUsd && (
+                  <div className="space-y-2">
+                    <h4 className="font-semibold text-sm flex items-center gap-2">
+                      <DollarSign className="h-4 w-4 text-green-600" />
+                      {t("logs.details.billingDetails.title")}
+                    </h4>
+                    <div className="rounded-md border bg-muted/50 p-4">
+                      <div className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm">
+                        <div className="flex justify-between">
+                          <span className="text-muted-foreground">
+                            {t("logs.billingDetails.input")}:
+                          </span>
+                          <span className="font-mono">{formatTokenAmount(inputTokens)} tokens</span>
+                        </div>
+                        <div className="flex justify-between">
+                          <span className="text-muted-foreground">
+                            {t("logs.billingDetails.output")}:
+                          </span>
+                          <span className="font-mono">
+                            {formatTokenAmount(outputTokens)} tokens
+                          </span>
+                        </div>
+                        {(cacheCreation5mInputTokens ?? 0) > 0 && (
+                          <div className="flex justify-between">
+                            <span className="text-muted-foreground">
+                              {t("logs.billingDetails.cacheWrite5m")}:
+                            </span>
+                            <span className="font-mono">
+                              {formatTokenAmount(cacheCreation5mInputTokens)} tokens{" "}
+                              <span className="text-orange-600">(1.25x)</span>
+                            </span>
+                          </div>
+                        )}
+                        {(cacheCreation1hInputTokens ?? 0) > 0 && (
+                          <div className="flex justify-between">
+                            <span className="text-muted-foreground">
+                              {t("logs.billingDetails.cacheWrite1h")}:
+                            </span>
+                            <span className="font-mono">
+                              {formatTokenAmount(cacheCreation1hInputTokens)} tokens{" "}
+                              <span className="text-orange-600">(2x)</span>
+                            </span>
+                          </div>
+                        )}
+                        {(cacheReadInputTokens ?? 0) > 0 && (
+                          <div className="flex justify-between">
+                            <span className="text-muted-foreground">
+                              {t("logs.billingDetails.cacheRead")}:
+                            </span>
+                            <span className="font-mono">
+                              {formatTokenAmount(cacheReadInputTokens)} tokens{" "}
+                              <span className="text-green-600">(0.1x)</span>
+                            </span>
+                          </div>
+                        )}
+                        {cacheTtlApplied && (
+                          <div className="flex justify-between">
+                            <span className="text-muted-foreground">
+                              {t("logs.billingDetails.cacheTtl")}:
+                            </span>
+                            <Badge variant="outline" className="text-xs">
+                              {cacheTtlApplied}
+                            </Badge>
+                          </div>
+                        )}
+                        {context1mApplied && (
+                          <div className="flex justify-between items-center gap-2">
+                            <span className="text-muted-foreground shrink-0">
+                              {t("logs.billingDetails.context1m")}:
+                            </span>
+                            <div className="flex items-center gap-2 min-w-0">
+                              <Badge
+                                variant="outline"
+                                className="text-xs bg-purple-50 text-purple-700 border-purple-200 dark:bg-purple-950/30 dark:text-purple-300 dark:border-purple-800 shrink-0"
+                              >
+                                1M Context
+                              </Badge>
+                              <span className="text-xs text-muted-foreground truncate">
+                                ({t("logs.billingDetails.context1mPricing")})
+                              </span>
+                            </div>
+                          </div>
+                        )}
+                        {costMultiplier && parseFloat(String(costMultiplier)) !== 1.0 && (
+                          <div className="flex justify-between">
+                            <span className="text-muted-foreground">
+                              {t("logs.billingDetails.multiplier")}:
+                            </span>
+                            <span className="font-mono">
+                              {parseFloat(String(costMultiplier)).toFixed(2)}x
+                            </span>
+                          </div>
+                        )}
+                      </div>
+                      <div className="mt-3 pt-3 border-t flex justify-between items-center">
+                        <span className="font-medium">{t("logs.billingDetails.totalCost")}:</span>
+                        <span className="font-mono text-lg font-semibold text-green-600">
+                          {formatCurrency(costUsd, "USD", 6)}
                         </span>
                       </div>
                     </div>
-                  )}
-                  {costMultiplier && parseFloat(String(costMultiplier)) !== 1.0 && (
-                    <div className="flex justify-between">
-                      <span className="text-muted-foreground">
-                        {t("logs.billingDetails.multiplier")}:
-                      </span>
-                      <span className="font-mono">
-                        {parseFloat(String(costMultiplier)).toFixed(2)}x
-                      </span>
-                    </div>
-                  )}
-                </div>
-                <div className="mt-3 pt-3 border-t flex justify-between items-center">
-                  <span className="font-medium">{t("logs.billingDetails.totalCost")}:</span>
-                  <span className="font-mono text-lg font-semibold text-green-600">
-                    {formatCurrency(costUsd, "USD", 6)}
-                  </span>
-                </div>
-              </div>
-            </div>
-          )}
-
-          {/* 性能数据 */}
-          {(durationMs != null || ttfbMs != null || (outputTokens ?? 0) > 0) && (
-            <div className="space-y-2">
-              <h4 className="font-semibold text-sm flex items-center gap-2">
-                <Gauge className="h-4 w-4 text-purple-600" />
-                {t("logs.details.performance.title")}
-              </h4>
-              <div className="rounded-md border bg-muted/50 p-4">
-                <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
-                  <div className="flex justify-between">
-                    <span className="text-muted-foreground">
-                      {t("logs.details.performance.ttfb")}:
-                    </span>
-                    <span className="font-mono">
-                      {ttfbMs != null ? `${Math.round(ttfbMs).toLocaleString()} ms` : "-"}
-                    </span>
-                  </div>
-                  <div className="flex justify-between">
-                    <span className="text-muted-foreground">
-                      {t("logs.details.performance.duration")}:
-                    </span>
-                    <span className="font-mono">
-                      {durationMs != null ? `${Math.round(durationMs).toLocaleString()} ms` : "-"}
-                    </span>
                   </div>
-                  <div className="flex justify-between col-span-2">
-                    <span className="text-muted-foreground">
-                      {t("logs.details.performance.outputRate")}:
-                    </span>
-                    <span className="font-mono">
-                      {outputTokensPerSecond !== null
-                        ? `${outputTokensPerSecond.toFixed(1)} tok/s`
-                        : "-"}
-                    </span>
+                )}
+
+                {/* 性能数据 */}
+                {(durationMs != null || ttfbMs != null || (outputTokens ?? 0) > 0) && (
+                  <div className="space-y-2">
+                    <h4 className="font-semibold text-sm flex items-center gap-2">
+                      <Gauge className="h-4 w-4 text-purple-600" />
+                      {t("logs.details.performance.title")}
+                    </h4>
+                    <div className="rounded-md border bg-muted/50 p-4">
+                      <div className="grid grid-cols-1 gap-x-6 gap-y-2 text-sm">
+                        <div className="flex justify-between">
+                          <span className="text-muted-foreground">
+                            {t("logs.details.performance.ttfb")}:
+                          </span>
+                          <span className="font-mono">
+                            {ttfbMs != null ? `${Math.round(ttfbMs).toLocaleString()} ms` : "-"}
+                          </span>
+                        </div>
+                        <div className="flex justify-between">
+                          <span className="text-muted-foreground">
+                            {t("logs.details.performance.duration")}:
+                          </span>
+                          <span className="font-mono">
+                            {durationMs != null
+                              ? `${Math.round(durationMs).toLocaleString()} ms`
+                              : "-"}
+                          </span>
+                        </div>
+                        <div className="flex justify-between">
+                          <span className="text-muted-foreground">
+                            {t("logs.details.performance.outputRate")}:
+                          </span>
+                          <span className="font-mono">
+                            {outputTokensPerSecond !== null
+                              ? `${outputTokensPerSecond.toFixed(1)} tok/s`
+                              : "-"}
+                          </span>
+                        </div>
+                      </div>
+                    </div>
                   </div>
-                </div>
+                )}
               </div>
-            </div>
-          )}
+            );
+          })()}
 
           {/* 模型重定向信息 */}
           {originalModel && currentModel && originalModel !== currentModel && (

+ 21 - 1
src/app/v1/_lib/proxy/response-handler.ts

@@ -1291,8 +1291,28 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
     }
   }
 
+  // 兼容顶层扁平格式:cache_creation_5m_input_tokens / cache_creation_1h_input_tokens
+  // 部分供应商/relay 直接在顶层返回细分字段,而非嵌套在 cache_creation 对象中
+  // 优先级:嵌套格式 > 顶层扁平格式 > 旧 relay 格式
+  if (
+    result.cache_creation_5m_input_tokens === undefined &&
+    typeof usage.cache_creation_5m_input_tokens === "number"
+  ) {
+    result.cache_creation_5m_input_tokens = usage.cache_creation_5m_input_tokens;
+    cacheCreationDetailedTotal += usage.cache_creation_5m_input_tokens;
+    hasAny = true;
+  }
+  if (
+    result.cache_creation_1h_input_tokens === undefined &&
+    typeof usage.cache_creation_1h_input_tokens === "number"
+  ) {
+    result.cache_creation_1h_input_tokens = usage.cache_creation_1h_input_tokens;
+    cacheCreationDetailedTotal += usage.cache_creation_1h_input_tokens;
+    hasAny = true;
+  }
+
   // 兼容部分 relay / 旧字段命名:claude_cache_creation_5_m_tokens / claude_cache_creation_1_h_tokens
-  // 仅在标准字段缺失时使用,避免重复统计
+  // 仅在标准字段缺失时使用,避免重复统计(优先级最低)
   if (
     result.cache_creation_5m_input_tokens === undefined &&
     typeof usage.claude_cache_creation_5_m_tokens === "number"

+ 509 - 0
tests/unit/proxy/extract-usage-metrics.test.ts

@@ -0,0 +1,509 @@
+import { describe, it, expect } from "vitest";
+
+// 由于 extractUsageMetrics 是内部函数,需要通过 parseUsageFromResponseText 间接测试
+// 或者将其导出用于测试
+// 这里我们通过构造 JSON 响应来测试 parseUsageFromResponseText
+
+import { parseUsageFromResponseText } from "@/app/v1/_lib/proxy/response-handler";
+
+describe("extractUsageMetrics", () => {
+  describe("基本 token 提取", () => {
+    it("应正确提取 input_tokens 和 output_tokens", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          output_tokens: 500,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(1000);
+      expect(result.usageMetrics?.output_tokens).toBe(500);
+    });
+
+    it("空值或非对象应返回 null", () => {
+      expect(parseUsageFromResponseText("", "claude").usageMetrics).toBeNull();
+      expect(parseUsageFromResponseText("null", "claude").usageMetrics).toBeNull();
+      expect(parseUsageFromResponseText('"string"', "claude").usageMetrics).toBeNull();
+    });
+  });
+
+  describe("Claude 嵌套格式 (cache_creation.ephemeral_*)", () => {
+    it("应从 cache_creation 嵌套对象提取 5m 和 1h token", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          output_tokens: 500,
+          cache_creation_input_tokens: 800,
+          cache_creation: {
+            ephemeral_5m_input_tokens: 300,
+            ephemeral_1h_input_tokens: 500,
+          },
+          cache_read_input_tokens: 200,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(800);
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
+      expect(result.usageMetrics?.cache_ttl).toBe("mixed");
+    });
+
+    it("只有 5m 时应推断 cache_ttl 为 5m", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_input_tokens: 300,
+          cache_creation: {
+            ephemeral_5m_input_tokens: 300,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBeUndefined();
+      expect(result.usageMetrics?.cache_ttl).toBe("5m");
+    });
+
+    it("只有 1h 时应推断 cache_ttl 为 1h", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_input_tokens: 500,
+          cache_creation: {
+            ephemeral_1h_input_tokens: 500,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBeUndefined();
+      expect(result.usageMetrics?.cache_ttl).toBe("1h");
+    });
+  });
+
+  describe("旧 relay 格式 (claude_cache_creation_*)", () => {
+    it("应从旧 relay 字段提取 5m 和 1h token", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          output_tokens: 500,
+          cache_creation_input_tokens: 800,
+          claude_cache_creation_5_m_tokens: 300,
+          claude_cache_creation_1_h_tokens: 500,
+          cache_read_input_tokens: 200,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_ttl).toBe("mixed");
+    });
+
+    it("嵌套格式应优先于旧 relay 格式", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation: {
+            ephemeral_5m_input_tokens: 100,
+            ephemeral_1h_input_tokens: 200,
+          },
+          claude_cache_creation_5_m_tokens: 999,
+          claude_cache_creation_1_h_tokens: 888,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      // 嵌套格式优先
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(100);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(200);
+    });
+  });
+
+  describe("顶层扁平格式 (cache_creation_5m_input_tokens)", () => {
+    it("应从顶层扁平字段提取 5m 和 1h token", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          output_tokens: 500,
+          cache_creation_input_tokens: 800,
+          cache_creation_5m_input_tokens: 300,
+          cache_creation_1h_input_tokens: 500,
+          cache_read_input_tokens: 200,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(800);
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
+      expect(result.usageMetrics?.cache_ttl).toBe("mixed");
+    });
+
+    it("只有顶层 5m 时应正确提取并推断 TTL", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_input_tokens: 300,
+          cache_creation_5m_input_tokens: 300,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_ttl).toBe("5m");
+    });
+
+    it("只有顶层 1h 时应正确提取并推断 TTL", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_input_tokens: 500,
+          cache_creation_1h_input_tokens: 500,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_ttl).toBe("1h");
+    });
+
+    it("嵌套格式应优先于顶层扁平格式", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation: {
+            ephemeral_5m_input_tokens: 100,
+            ephemeral_1h_input_tokens: 200,
+          },
+          cache_creation_5m_input_tokens: 999,
+          cache_creation_1h_input_tokens: 888,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      // 嵌套格式优先
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(100);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(200);
+    });
+
+    it("顶层扁平格式应优先于旧 relay 格式", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_5m_input_tokens: 300,
+          cache_creation_1h_input_tokens: 500,
+          claude_cache_creation_5_m_tokens: 999,
+          claude_cache_creation_1_h_tokens: 888,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      // 顶层扁平格式优先于旧 relay 格式
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(500);
+    });
+
+    it("三种格式同时存在时应按优先级提取", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation: {
+            ephemeral_5m_input_tokens: 100,
+            ephemeral_1h_input_tokens: 200,
+          },
+          cache_creation_5m_input_tokens: 300,
+          cache_creation_1h_input_tokens: 400,
+          claude_cache_creation_5_m_tokens: 500,
+          claude_cache_creation_1_h_tokens: 600,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      // 嵌套格式最优先
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(100);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(200);
+      expect(result.usageMetrics?.cache_ttl).toBe("mixed");
+    });
+  });
+
+  describe("cache_creation_input_tokens 自动计算", () => {
+    it("当 cache_creation_input_tokens 缺失时应自动计算总量", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation: {
+            ephemeral_5m_input_tokens: 300,
+            ephemeral_1h_input_tokens: 500,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(800);
+    });
+
+    it("顶层扁平格式缺失 cache_creation_input_tokens 时应自动计算总量", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_5m_input_tokens: 400,
+          cache_creation_1h_input_tokens: 600,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(1000);
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(400);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(600);
+    });
+
+    it("混合回退:嵌套缺失某字段时顶层扁平补齐", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation: {
+            ephemeral_5m_input_tokens: 200,
+            // 缺失 ephemeral_1h_input_tokens
+          },
+          cache_creation_1h_input_tokens: 300, // 顶层扁平补齐
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      // 5m 来自嵌套,1h 来自顶层扁平
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(200);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_ttl).toBe("mixed");
+    });
+
+    it("当 cache_creation_input_tokens 存在时不应覆盖", () => {
+      const response = JSON.stringify({
+        usage: {
+          cache_creation_input_tokens: 1000,
+          cache_creation: {
+            ephemeral_5m_input_tokens: 300,
+            ephemeral_1h_input_tokens: 500,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      // 保留原值
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(1000);
+    });
+  });
+
+  describe("Gemini 格式支持", () => {
+    it("应正确提取 Gemini usage 字段", () => {
+      const response = JSON.stringify({
+        usageMetadata: {
+          promptTokenCount: 1000,
+          candidatesTokenCount: 500,
+          cachedContentTokenCount: 200,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "gemini");
+
+      expect(result.usageMetrics).not.toBeNull();
+      // input_tokens = promptTokenCount - cachedContentTokenCount
+      expect(result.usageMetrics?.input_tokens).toBe(800);
+      expect(result.usageMetrics?.output_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
+    });
+
+    it("应正确处理 Gemini thoughtsTokenCount", () => {
+      const response = JSON.stringify({
+        usageMetadata: {
+          promptTokenCount: 1000,
+          candidatesTokenCount: 500,
+          thoughtsTokenCount: 100,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "gemini");
+
+      // output_tokens = candidatesTokenCount + thoughtsTokenCount
+      expect(result.usageMetrics?.output_tokens).toBe(600);
+    });
+  });
+
+  describe("OpenAI Response API 格式", () => {
+    it("应从 input_tokens_details.cached_tokens 提取缓存读取", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          output_tokens: 500,
+          input_tokens_details: {
+            cached_tokens: 200,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(200);
+    });
+
+    it("顶层 cache_read_input_tokens 应优先于嵌套格式", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          cache_read_input_tokens: 300,
+          input_tokens_details: {
+            cached_tokens: 200,
+          },
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "openai");
+
+      // 顶层优先
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(300);
+    });
+  });
+
+  describe("SSE 流式响应解析", () => {
+    it("应正确合并 message_start 和 message_delta 的 usage", () => {
+      // 模拟 Claude SSE 流式响应
+      const sseResponse = [
+        "event: message_start",
+        'data: {"type":"message_start","message":{"usage":{"input_tokens":1000,"cache_creation_input_tokens":500,"cache_creation":{"ephemeral_5m_input_tokens":200,"ephemeral_1h_input_tokens":300},"cache_read_input_tokens":100}}}',
+        "",
+        "event: message_delta",
+        'data: {"type":"message_delta","usage":{"output_tokens":800}}',
+        "",
+      ].join("\n");
+
+      const result = parseUsageFromResponseText(sseResponse, "claude");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(1000);
+      expect(result.usageMetrics?.output_tokens).toBe(800);
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(200);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(300);
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(100);
+    });
+
+    it("message_delta 的值应优先于 message_start", () => {
+      const sseResponse = [
+        "event: message_start",
+        'data: {"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}',
+        "",
+        "event: message_delta",
+        'data: {"type":"message_delta","usage":{"input_tokens":1000,"output_tokens":500}}',
+        "",
+      ].join("\n");
+
+      const result = parseUsageFromResponseText(sseResponse, "claude");
+
+      // message_delta 优先
+      expect(result.usageMetrics?.input_tokens).toBe(1000);
+      expect(result.usageMetrics?.output_tokens).toBe(500);
+    });
+
+    it("message_start 的 cache 细分应补充 message_delta 缺失的字段", () => {
+      const sseResponse = [
+        "event: message_start",
+        'data: {"type":"message_start","message":{"usage":{"cache_creation":{"ephemeral_5m_input_tokens":200,"ephemeral_1h_input_tokens":300}}}}',
+        "",
+        "event: message_delta",
+        'data: {"type":"message_delta","usage":{"input_tokens":1000,"output_tokens":500,"cache_creation_input_tokens":500}}',
+        "",
+      ].join("\n");
+
+      const result = parseUsageFromResponseText(sseResponse, "claude");
+
+      // message_delta 的值
+      expect(result.usageMetrics?.input_tokens).toBe(1000);
+      expect(result.usageMetrics?.output_tokens).toBe(500);
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBe(500);
+      // message_start 补充的细分字段
+      expect(result.usageMetrics?.cache_creation_5m_input_tokens).toBe(200);
+      expect(result.usageMetrics?.cache_creation_1h_input_tokens).toBe(300);
+    });
+  });
+
+  describe("Codex provider 特殊处理", () => {
+    it("Codex 应从 input_tokens 中减去 cached_tokens", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+          output_tokens: 500,
+          cache_read_input_tokens: 300,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "codex");
+
+      // adjustUsageForProviderType 会调整 input_tokens
+      expect(result.usageMetrics?.input_tokens).toBe(700); // 1000 - 300
+      expect(result.usageMetrics?.cache_read_input_tokens).toBe(300);
+    });
+  });
+
+  describe("边界情况", () => {
+    it("应处理所有值为 0 的情况", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 0,
+          output_tokens: 0,
+          cache_creation_input_tokens: 0,
+          cache_read_input_tokens: 0,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics).not.toBeNull();
+      expect(result.usageMetrics?.input_tokens).toBe(0);
+      expect(result.usageMetrics?.output_tokens).toBe(0);
+    });
+
+    it("应处理部分字段缺失的情况", () => {
+      const response = JSON.stringify({
+        usage: {
+          input_tokens: 1000,
+        },
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics?.input_tokens).toBe(1000);
+      expect(result.usageMetrics?.output_tokens).toBeUndefined();
+      expect(result.usageMetrics?.cache_creation_input_tokens).toBeUndefined();
+    });
+
+    it("应处理无效的 JSON", () => {
+      const result = parseUsageFromResponseText("invalid json", "claude");
+
+      expect(result.usageMetrics).toBeNull();
+    });
+
+    it("应处理空的 usage 对象", () => {
+      const response = JSON.stringify({
+        usage: {},
+      });
+
+      const result = parseUsageFromResponseText(response, "claude");
+
+      expect(result.usageMetrics).toBeNull();
+    });
+  });
+});