dsh-router-core 0.3.16 → 0.3.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3201,6 +3201,7 @@ window.__ModuleLoader__.load({
3201
3201
  }, [period, active]);
3202
3202
  const s = stats;
3203
3203
  const successRate = s !== null && s.requests !== void 0 && s.requests > 0 ? Math.round((s.okCount ?? 0) / s.requests * 100) : null;
3204
+ const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0 ? Math.round((s.cachedTokens ?? 0) / (s.promptTokens ?? 0) * 100) : null;
3204
3205
  const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0);
3205
3206
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3206
3207
  className: "dshr-tabBody",
@@ -3276,10 +3277,11 @@ window.__ModuleLoader__.load({
3276
3277
  tone: "ok"
3277
3278
  }),
3278
3279
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3279
- label: "缓存 Tokens",
3280
- value: fmtShort(s?.cachedTokens),
3281
- hint: "命中缓存的输入部分",
3282
- tone: "default"
3280
+ label: "缓存命中率",
3281
+ value: cacheRatio === null ? "—" : `${cacheRatio}%`,
3282
+ hint: cacheRatio === null ? "命中缓存的输入占比" : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`,
3283
+ tone: cacheRatio !== null && cacheRatio >= 80 ? "ok" : "default",
3284
+ title: "缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
3283
3285
  }),
3284
3286
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3285
3287
  label: "平均耗时",
package/lib/client.js CHANGED
@@ -3201,6 +3201,7 @@ window.__ModuleLoader__.load({
3201
3201
  }, [period, active]);
3202
3202
  const s = stats;
3203
3203
  const successRate = s !== null && s.requests !== void 0 && s.requests > 0 ? Math.round((s.okCount ?? 0) / s.requests * 100) : null;
3204
+ const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0 ? Math.round((s.cachedTokens ?? 0) / (s.promptTokens ?? 0) * 100) : null;
3204
3205
  const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0);
3205
3206
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
3206
3207
  className: "dshr-tabBody",
@@ -3276,10 +3277,11 @@ window.__ModuleLoader__.load({
3276
3277
  tone: "ok"
3277
3278
  }),
3278
3279
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3279
- label: "缓存 Tokens",
3280
- value: fmtShort(s?.cachedTokens),
3281
- hint: "命中缓存的输入部分",
3282
- tone: "default"
3280
+ label: "缓存命中率",
3281
+ value: cacheRatio === null ? "—" : `${cacheRatio}%`,
3282
+ hint: cacheRatio === null ? "命中缓存的输入占比" : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`,
3283
+ tone: cacheRatio !== null && cacheRatio >= 80 ? "ok" : "default",
3284
+ title: "缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
3283
3285
  }),
3284
3286
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(StatCard, {
3285
3287
  label: "平均耗时",
package/lib/index.js CHANGED
@@ -1052,7 +1052,14 @@ function num(v) {
1052
1052
  * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
1053
1053
  * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
1054
1054
  * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
1055
- * 判别式就是「有没有 cache_read_input_tokens」。
1055
+ *
1056
+ * 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
1057
+ * Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
1058
+ * 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
1059
+ * 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
1060
+ * 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
1061
+ * 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
1062
+ * 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
1056
1063
  *
1057
1064
  * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
1058
1065
  */
@@ -1061,7 +1068,7 @@ function normalizeUsage(raw) {
1061
1068
  const u = raw;
1062
1069
  const cached = num(u.cached_tokens) + num(u.cache_read_input_tokens) + num(u.cachedContentTokenCount) + num(u.prompt_tokens_details?.cached_tokens);
1063
1070
  let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount);
1064
- if (u.cache_read_input_tokens !== void 0) prompt += num(u.cache_read_input_tokens);
1071
+ if (u.cache_read_input_tokens !== void 0 && u.prompt_tokens === void 0) prompt += num(u.cache_read_input_tokens);
1065
1072
  const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount);
1066
1073
  if (prompt === 0 && completion === 0 && cached === 0) return null;
1067
1074
  return {
@@ -38,7 +38,14 @@ export interface Usage extends UsageTokens {
38
38
  * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
39
39
  * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
40
40
  * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
41
- * 判别式就是「有没有 cache_read_input_tokens」。
41
+ *
42
+ * 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
43
+ * Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
44
+ * 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
45
+ * 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
46
+ * 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
47
+ * 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
48
+ * 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
42
49
  *
43
50
  * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
44
51
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-router-core",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "DSH plugin: a simplified 9router (AI routing gateway). Adds a 路由系统 (Routing System) sidebar entry beside 记忆系统; the panel shows suppliers, account pool, combos, and endpoint/API keys.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -421,6 +421,12 @@ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProp
421
421
  const successRate = s !== null && s.requests !== undefined && s.requests > 0
422
422
  ? Math.round(((s.okCount ?? 0) / s.requests) * 100)
423
423
  : null
424
+ // 缓存命中率 = cachedTokens / promptTokens(cachedTokens 是 promptTokens
425
+ // 的子集:命中缓存的输入部分)。分母 0 = 本周期没有带缓存语义的请求,
426
+ // 显示 — 而不是硬造 0%(那会把「没数据」和「全未命中」混为一谈)。
427
+ const cacheRatio = s !== null && (s.promptTokens ?? 0) > 0
428
+ ? Math.round(((s.cachedTokens ?? 0) / (s.promptTokens ?? 0)) * 100)
429
+ : null
424
430
  // 估算提示:只有真有估算时才显示,别没事吓唬人
425
431
  const estimated = s !== null && ((s.estimatedInputs ?? 0) > 0 || (s.estimatedOutputs ?? 0) > 0)
426
432
 
@@ -490,8 +496,16 @@ export function StatsTab({ onRefresh, refreshing, active, health }: StatsTabProp
490
496
  hint={(s?.estimatedOutputs ?? 0) > 0 ? '~ 含估算' : undefined}
491
497
  tone="ok"
492
498
  />
493
- {/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token */}
494
- <StatCard label="缓存 Tokens" value={fmtShort(s?.cachedTokens)} hint="命中缓存的输入部分" tone="default" />
499
+ {/* 缓存排在输入/输出之后:它是输入的子集,不是并列的第三种 token。
500
+ 显示命中率而不是 token 数:50.2% 这种数一眼能读出「缓存工作是否正常」,
501
+ 绝对值要看的话输入 Tokens 卡里已含(title 给出原始数字)。 */}
502
+ <StatCard
503
+ label="缓存命中率"
504
+ value={cacheRatio === null ? '—' : `${cacheRatio}%`}
505
+ hint={cacheRatio === null ? '命中缓存的输入占比' : `命中 ${fmtShort(s?.cachedTokens)} / 输入 ${fmtShort(s?.promptTokens)}`}
506
+ tone={cacheRatio !== null && cacheRatio >= 80 ? 'ok' : 'default'}
507
+ title="缓存命中率 = 命中缓存的输入 tokens ÷ 总输入 tokens"
508
+ />
495
509
  <StatCard
496
510
  label="平均耗时"
497
511
  value={fmtDuration(s?.avgDurationMs)}
@@ -61,7 +61,14 @@ function num(v: unknown): number {
61
61
  * `cached_tokens` 口径照 9router 的 `canonicalizeUsage`:OpenAI 系报的
62
62
  * prompt_tokens **已含**缓存,直接透传;Claude 系报的是**不含**缓存的
63
63
  * prompt + 单独的 cache_read,要折进来,否则总输入偏小。
64
- * 判别式就是「有没有 cache_read_input_tokens」。
64
+ *
65
+ * 判别式**不能只看有没有 `cache_read_input_tokens`**:TRAE(SOLO) 借用了这个
66
+ * Claude 字段名,语义却是 OpenAI 的「prompt_tokens 已含缓存」——
67
+ * 于是同时报了 `prompt_tokens=160019` 和 `cache_read_input_tokens=159744`,
68
+ * 旧逻辑无条件相加 → 总量翻倍、命中率被腰斩成 50%。正确判别是**看 prompt
69
+ * 车道**:有 `prompt_tokens` = OpenAI 总语义(已含缓存,**不**再折 cache_read);
70
+ * 只有 `input_tokens`/`promptTokenCount` = Claude/Gemini 不含缓存,才折。
71
+ * 这与供应商无关、自描述,比把 supplierId 灌进 usage 管线更干净。
65
72
  *
66
73
  * @returns 归一用量;一个非零字段都没有时返回 null(= 没拿到 usage)
67
74
  */
@@ -77,7 +84,11 @@ export function normalizeUsage(raw: unknown): UsageTokens | null {
77
84
 
78
85
  // promptTokenCount 是 Gemini 形态;input_tokens 是 Claude 形态
79
86
  let prompt = num(u.prompt_tokens) + num(u.input_tokens) + num(u.promptTokenCount)
80
- if (u.cache_read_input_tokens !== undefined) prompt += num(u.cache_read_input_tokens)
87
+ // 有 prompt_tokens = OpenAI 总语义(含缓存);没有 = Claude/Gemini 不含缓存,
88
+ // 这时才该把 cache_read 折进来,否则像 TRAE 会加两遍
89
+ if (u.cache_read_input_tokens !== undefined && u.prompt_tokens === undefined) {
90
+ prompt += num(u.cache_read_input_tokens)
91
+ }
81
92
 
82
93
  const completion = num(u.completion_tokens) + num(u.output_tokens) + num(u.candidatesTokenCount)
83
94
 
@@ -12,6 +12,7 @@ import {
12
12
  tapStreamUsage,
13
13
  withEstimates,
14
14
  estimateTokens,
15
+ toTokenUsage,
15
16
  } from './usage-tokens.ts'
16
17
  import { UsageStore, localDateKey } from './usage-store.ts'
17
18
  import { mkdirSync, writeFileSync } from 'node:fs'
@@ -35,6 +36,22 @@ test('归一化:Claude 形态的 prompt 不含缓存,cache_read 要折进输
35
36
  assert.deepEqual(u, { promptTokens: 130, completionTokens: 10, cachedTokens: 80 })
36
37
  })
37
38
 
39
+ test('归一化:TRAE 借 Claude 字段名、语义却是 OpenAI —— 不得重复加 cache(命中率腰斩 bug)', () => {
40
+ // TRAE(SOLO) 同时报 prompt_tokens(OpenAI 总语义,**已含**缓存)和
41
+ // cache_read_input_tokens。语义与字段名分属两条车道,判别不能只看后者。
42
+ // 2026-09-16 实测同一次请求:160019 + 159744 被加两遍 → 319763,命中率腰斩
43
+ // 成 ~50%;真实命中率 159744/160019 = 99.8%。
44
+ const u = normalizeUsage({
45
+ prompt_tokens: 160019,
46
+ completion_tokens: 307,
47
+ cache_read_input_tokens: 159744,
48
+ })
49
+ assert.deepEqual(u, { promptTokens: 160019, completionTokens: 307, cachedTokens: 159744 }, 'prompt_tokens 已含缓存,不得再加 cache_read')
50
+ // DISJOINT 折算后必须还原上游真实值:160019 - 159744 = 275 未缓存输入
51
+ const t = toTokenUsage(u)
52
+ assert.deepEqual(t, { inputTokens: 275, outputTokens: 307, cacheReadTokens: 159744 })
53
+ })
54
+
38
55
  test('归一化:Gemini 形态(promptTokenCount / usageMetadata)', () => {
39
56
  const u = normalizeUsage({ promptTokenCount: 7, candidatesTokenCount: 3, cachedContentTokenCount: 2 })
40
57
  assert.deepEqual(u, { promptTokens: 7, completionTokens: 3, cachedTokens: 2 })