dsh-token-total 1.0.3 → 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.
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 }>,