dsh-token-total 1.0.2 → 1.0.4

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.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The stat-pill glyphs.
3
+ *
4
+ * The shell freezes its primitives table, and that table's icon names moved
5
+ * with the host line: 0.2.1-rc.2 exports `IconGaugeOutlineRegular` /
6
+ * `IconDatabaseOutlineRegular` (plus the `Medium` weight) and no longer
7
+ * exports the `…Outline16` names this plugin used to import. A missing named
8
+ * export is `undefined` at runtime, and React then throws "Element type is
9
+ * invalid" on the entry's first render — the shell's slot boundary catches it,
10
+ * ABDICATES the entry, and the shadowed official pill silently returns. That
11
+ * failure mode is invisible in types (the package's own declarations still
12
+ * describe the old names) and looks exactly like "the plugin did nothing".
13
+ *
14
+ * So the glyph is resolved from the runtime table by preference order and
15
+ * falls back to an inline drawing of the same shape when no name exists, which
16
+ * keeps one published build rendering on every host line.
17
+ */
18
+ import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
19
+
20
+ type IconComponent = () => JSX.Element
21
+
22
+ const table = primitives as unknown as Record<string, unknown>
23
+
24
+ /** First exported function component among `names`, or null when the shell
25
+ * exports none of them (an older or newer icon roster). */
26
+ function resolveIcon(...names: readonly string[]): IconComponent | null {
27
+ for (const name of names) {
28
+ const candidate = table[name]
29
+ if (typeof candidate === 'function') return candidate as IconComponent
30
+ }
31
+ return null
32
+ }
33
+
34
+ const resolvedGauge = resolveIcon(
35
+ 'IconGaugeOutlineRegular',
36
+ 'IconGaugeOutlineMedium',
37
+ 'IconGaugeOutline16',
38
+ )
39
+ const resolvedDatabase = resolveIcon(
40
+ 'IconDatabaseOutlineRegular',
41
+ 'IconDatabaseOutlineMedium',
42
+ 'IconDatabaseOutline16',
43
+ )
44
+
45
+ /** Gauge glyph for the activity pill (counts + throughput). */
46
+ export function GaugeIcon(): JSX.Element {
47
+ if (resolvedGauge !== null) {
48
+ const Icon = resolvedGauge
49
+ return <Icon />
50
+ }
51
+ return (
52
+ <svg viewBox="0 0 16 16" aria-hidden="true">
53
+ <path
54
+ d="M8 2.5a5.5 5.5 0 0 1 5.5 5.5M8 8l2.6-2.6M2.5 8A5.5 5.5 0 0 1 8 2.5"
55
+ fill="none"
56
+ stroke="currentColor"
57
+ strokeWidth="1.3"
58
+ strokeLinecap="round"
59
+ />
60
+ <circle cx="8" cy="8" r="1" fill="currentColor" />
61
+ </svg>
62
+ )
63
+ }
64
+
65
+ /** Database glyph for the token-usage pill. */
66
+ export function DatabaseIcon(): JSX.Element {
67
+ if (resolvedDatabase !== null) {
68
+ const Icon = resolvedDatabase
69
+ return <Icon />
70
+ }
71
+ return (
72
+ <svg viewBox="0 0 16 16" aria-hidden="true">
73
+ <ellipse cx="8" cy="3.6" rx="4.6" ry="1.9" fill="none" stroke="currentColor" strokeWidth="1.3" />
74
+ <path
75
+ d="M3.4 3.6v8.8c0 1.05 2.06 1.9 4.6 1.9s4.6-.85 4.6-1.9V3.6M3.4 8c0 1.05 2.06 1.9 4.6 1.9s4.6-.85 4.6-1.9"
76
+ fill="none"
77
+ stroke="currentColor"
78
+ strokeWidth="1.3"
79
+ strokeLinecap="round"
80
+ />
81
+ </svg>
82
+ )
83
+ }
package/src/query.ts CHANGED
@@ -104,6 +104,9 @@ export function aggregateSamples(
104
104
  const from = filter.from
105
105
  const to = filter.to
106
106
  const buckets = bucketsInRange(from, to, granularity)
107
+ // Materialize once: the qualified-name pre-pass below and the main fold
108
+ // both walk the samples, and a generator input may only be consumed once.
109
+ const all = [...samples]
107
110
  const out: TokenTotalRange = {
108
111
  from,
109
112
  to,
@@ -151,7 +154,11 @@ export function aggregateSamples(
151
154
  const bucketCacheHit = new Map<string, number>()
152
155
  const bucketCacheMiss = new Map<string, number>()
153
156
 
154
- for (const sample of samples) {
157
+ // One model, one row: every qualified routing name folds onto its bare
158
+ // model key before the first aggregation (see aliasQualifiedModels).
159
+ const modelAlias = aliasQualifiedModels(new Set(all.map((sample) => modelKeyOf(sample))))
160
+
161
+ for (const sample of all) {
155
162
  const bucket = bucketOf(sample, from, to, granularity)
156
163
  if (bucket === null) continue
157
164
  if (sample.turn) {
@@ -192,7 +199,8 @@ export function aggregateSamples(
192
199
  out.cacheMiss += missSide
193
200
  const modelRef = sample.model && sample.model !== '' ? sample.model : '(unknown)'
194
201
  const provider = providerOf(modelRef)
195
- const model = normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef))
202
+ const modelBase = modelKeyOf(sample)
203
+ const model = modelAlias.get(modelBase) ?? modelBase
196
204
  const sources = modelProviders.get(model) ?? new Set<string>()
197
205
  sources.add(provider)
198
206
  modelProviders.set(model, sources)
@@ -272,6 +280,49 @@ export function aggregateSamples(
272
280
  return out
273
281
  }
274
282
 
283
+ /** The aggregation key of one sample: the resolved display name when the
284
+ * host resolved one, else the bare model ref — normalized like every
285
+ * display name so the key is stable across routes and refreshes. */
286
+ function modelKeyOf(sample: UsageSample): string {
287
+ const modelRef = sample.model && sample.model !== '' ? sample.model : '(unknown)'
288
+ return normalizeModelName(sample.modelName?.trim() || modelNameOf(modelRef))
289
+ }
290
+
291
+ /**
292
+ * Qualified-name aliases: "workbuddy-ai-plugin/deepseek-v4.1-flash" and
293
+ * "deepseek-v4.1-flash" are the same model reached through a routing
294
+ * namespace, so a name containing "/" merges onto the LONGEST suffix after a
295
+ * "/" that is itself a model key in this dataset (chains like a/b/c → b/c → c
296
+ * resolve transitively). The merge is dataset-relative on purpose: a slash
297
+ * that names no bare model in the data ("GPT/6.1 sol" beside "gpt-6.1-sol")
298
+ * is a genuinely different name and stays its own row — see the
299
+ * distinct-slashes case in tests/model-aggregation.test.mjs.
300
+ */
301
+ function aliasQualifiedModels(names: Set<string>): Map<string, string> {
302
+ const alias = new Map<string, string>()
303
+ for (const name of names) {
304
+ if (!name.includes('/')) continue
305
+ const parts = name.split('/')
306
+ for (let i = 1; i < parts.length; i++) {
307
+ const suffix = parts.slice(i).join('/')
308
+ if (names.has(suffix)) {
309
+ alias.set(name, suffix)
310
+ break
311
+ }
312
+ }
313
+ }
314
+ for (const name of [...alias.keys()]) {
315
+ let target = alias.get(name)!
316
+ const seen = new Set([name])
317
+ while (alias.has(target) && !seen.has(target)) {
318
+ seen.add(target)
319
+ target = alias.get(target)!
320
+ }
321
+ alias.set(name, target)
322
+ }
323
+ return alias
324
+ }
325
+
275
326
  function modelsSorted(
276
327
  totals: Map<string, number>,
277
328
  cache: Map<string, { hit: number; miss: number }>,