@danypops/jittor 0.16.2 → 0.17.0

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.
Files changed (94) hide show
  1. package/README.md +38 -13
  2. package/docs/COMPACTION_EFFECTIVENESS.md +34 -0
  3. package/docs/CONTEXT_SNAPSHOTS.md +53 -0
  4. package/docs/MODEL_CATALOG.md +43 -0
  5. package/docs/OTLP_EXPORT.md +63 -0
  6. package/docs/PROVIDER_RESEARCH.md +1 -1
  7. package/docs/TOKEN_MEASUREMENT.md +53 -0
  8. package/docs/USAGE_BACKFILL.md +35 -0
  9. package/package.json +4 -3
  10. package/src/{providers/anthropic-contracts.ts → anthropic/rate-limits.ts} +1 -1
  11. package/src/{adapters/artificial-analysis-direct-source.ts → artificial-analysis/benchmark-source.ts} +3 -3
  12. package/src/cli-commands/backfill.ts +51 -0
  13. package/src/cli-commands/benchmarks.ts +4 -4
  14. package/src/cli-commands/catalog.ts +123 -0
  15. package/src/cli-commands/compaction.ts +1 -1
  16. package/src/cli-commands/context.ts +77 -4
  17. package/src/cli-commands/export.ts +30 -0
  18. package/src/cli-commands/metrics.ts +8 -2
  19. package/src/cli-commands/op.ts +1 -1
  20. package/src/cli-commands/route-args.ts +2 -2
  21. package/src/cli-commands/router.ts +2 -2
  22. package/src/cli-commands/support.ts +2 -2
  23. package/src/cli.ts +54 -21
  24. package/src/{providers/codex-contracts.ts → codex/contracts.ts} +1 -1
  25. package/src/codex/source.ts +53 -0
  26. package/src/{providers/codex.ts → codex/telemetry.ts} +2 -2
  27. package/src/constants.ts +18 -0
  28. package/src/daemon.ts +53 -23
  29. package/src/{providers/google-vertex-budget-contracts.ts → google-vertex/budget-contracts.ts} +3 -3
  30. package/src/{providers/google-vertex-budget.ts → google-vertex/budget-telemetry.ts} +6 -6
  31. package/src/{providers/google-vertex-contracts.ts → google-vertex/failures.ts} +1 -1
  32. package/src/google-vertex/source.ts +35 -0
  33. package/src/index.ts +135 -55
  34. package/src/{adapters/lmarena-hf-source.ts → lmarena/benchmark-source.ts} +3 -3
  35. package/src/observability/budget.ts +14 -0
  36. package/src/observability/context-delta.ts +309 -0
  37. package/src/{domain → observability}/context-hub.ts +19 -2
  38. package/src/observability/context-snapshot-history.ts +281 -0
  39. package/src/{domain → observability}/context-telemetry.ts +232 -4
  40. package/src/observability/model-identity.ts +42 -0
  41. package/src/{domain → observability}/model-observation.ts +26 -11
  42. package/src/{ports/metric-store.ts → observability/store.ts} +2 -2
  43. package/src/{ports → observability}/telemetry-source.ts +2 -2
  44. package/src/observability/token-measurement.ts +194 -0
  45. package/src/observability/usage-import.ts +122 -0
  46. package/src/openai/token-counter.ts +63 -0
  47. package/src/{adapters/openrouter-benchmark-source.ts → openrouter/benchmark-source.ts} +3 -3
  48. package/src/{providers/openrouter-contracts.ts → openrouter/contracts.ts} +1 -1
  49. package/src/{adapters/openrouter-design-arena-source.ts → openrouter/design-arena-source.ts} +4 -4
  50. package/src/openrouter/source.ts +20 -0
  51. package/src/{providers/openrouter.ts → openrouter/telemetry.ts} +2 -2
  52. package/src/{domain → optimization/model-selection}/benchmark.ts +9 -27
  53. package/src/optimization/model-selection/catalog-store.ts +97 -0
  54. package/src/optimization/model-selection/catalog.ts +478 -0
  55. package/src/{ports/benchmark-controller.ts → optimization/model-selection/controller.ts} +1 -1
  56. package/src/{adapters/metric-benchmark-store.ts → optimization/model-selection/observation-store.ts} +5 -5
  57. package/src/{domain/model-ranking-service.ts → optimization/model-selection/ranker.ts} +6 -12
  58. package/src/{domain/model-ranking.ts → optimization/model-selection/ranking.ts} +8 -2
  59. package/src/{ports/benchmark-source.ts → optimization/model-selection/source.ts} +1 -1
  60. package/src/{ports/benchmark-store.ts → optimization/model-selection/store.ts} +1 -1
  61. package/src/{domain/codex-recovery.ts → optimization/recovery/codex.ts} +1 -1
  62. package/src/{ports/router-controller.ts → optimization/routing/controller.ts} +1 -1
  63. package/src/{policy.ts → optimization/routing/policy.ts} +4 -14
  64. package/src/{router.ts → optimization/routing/router.ts} +4 -10
  65. package/src/otlp/exporter.ts +354 -0
  66. package/src/pi/session-usage-source.ts +203 -0
  67. package/src/{session-identity-service.ts → sessions/identity.ts} +2 -2
  68. package/src/{operations/session-scope.ts → sessions/router-authorization.ts} +1 -1
  69. package/src/{db.ts → sqlite/database.ts} +14 -1
  70. package/src/{adapters/sqlite-metric-store.ts → sqlite/metric-store.ts} +4 -4
  71. package/src/{adapters/sqlite-session-identity-store.ts → sqlite/session-store.ts} +1 -1
  72. package/src/sqlite/usage-import-store.ts +133 -0
  73. package/src/telemetry-export/exporter.ts +84 -0
  74. package/src/{operations → vehicle}/benchmark-operations.ts +3 -3
  75. package/src/vehicle/catalog-operations.ts +10 -0
  76. package/src/{client.ts → vehicle/client.ts} +1 -1
  77. package/src/{operations → vehicle}/context-operations.ts +13 -5
  78. package/src/vehicle/export-operations.ts +12 -0
  79. package/src/{operations/metrics-operations.ts → vehicle/metric-operations.ts} +4 -4
  80. package/src/{operations → vehicle}/model-ranking-operations.ts +3 -3
  81. package/src/{vehicle-registration.ts → vehicle/registration.ts} +14 -4
  82. package/src/{operations/router-operations.ts → vehicle/routing-operations.ts} +4 -4
  83. package/src/{service.ts → vehicle/service.ts} +108 -26
  84. package/src/{operations/session-identity-operations.ts → vehicle/session-operations.ts} +3 -3
  85. package/src/vehicle/usage-import-operations.ts +10 -0
  86. package/src/providers/telemetry-sources.ts +0 -108
  87. /package/src/{providers/google-adc-auth.ts → google-vertex/auth.ts} +0 -0
  88. /package/src/{domain → observability}/metric.ts +0 -0
  89. /package/src/{domain → observability}/task-cost.ts +0 -0
  90. /package/src/{domain → observability}/task-focus.ts +0 -0
  91. /package/src/{domain → observability}/usage.ts +0 -0
  92. /package/src/{config.ts → optimization/routing/config.ts} +0 -0
  93. /package/src/{ports/session-identity-store.ts → sessions/store.ts} +0 -0
  94. /package/src/{operations/types.ts → vehicle/operation-types.ts} +0 -0
@@ -7,6 +7,7 @@ import {
7
7
  PAPYRUS_CONTEXT_INJECTION_SCHEMA,
8
8
  } from "../constants.ts";
9
9
  import type { MetricObservation, StoredMetricObservation } from "./metric.ts";
10
+ import type { TokenMeasurementProvenance } from "./token-measurement.ts";
10
11
 
11
12
  interface PayloadSize {
12
13
  characters: number;
@@ -133,16 +134,50 @@ export function papyrusContextMetric(observation: PapyrusContextInjection): Metr
133
134
  };
134
135
  }
135
136
 
137
+ export type CompactionMechanism = "pi-native" | "provider-side" | "extension";
138
+
136
139
  export interface CompactionStart {
137
140
  reason: "manual" | "threshold" | "overflow";
138
141
  willRetry: boolean;
142
+ mechanism?: CompactionMechanism;
139
143
  contextPercent?: number;
140
144
  contextTokens?: number;
145
+ contextProvenance?: TokenMeasurementProvenance;
146
+ provider?: string;
147
+ model?: string;
148
+ }
149
+
150
+ export interface CompactionCompletion extends Pick<CompactionStart, "reason" | "willRetry"> {
151
+ mechanism?: CompactionMechanism;
152
+ summaryTokens?: number;
153
+ summaryProvenance?: TokenMeasurementProvenance;
141
154
  }
142
155
 
143
156
  interface OpenCompaction extends CompactionStart {
157
+ mechanism: CompactionMechanism;
144
158
  startedAt: number;
145
159
  }
160
+
161
+ interface PendingEffectiveness {
162
+ preContextTokens: number;
163
+ preContextProvenance: TokenMeasurementProvenance;
164
+ mechanism: CompactionMechanism;
165
+ provider?: string;
166
+ model?: string;
167
+ summaryTokens?: number;
168
+ summaryProvenance?: TokenMeasurementProvenance;
169
+ completedAt: number;
170
+ }
171
+
172
+ interface RegrowthState {
173
+ preContextTokens: number;
174
+ completedAt: number;
175
+ turns: number;
176
+ emitted: Set<number>;
177
+ mechanism: CompactionMechanism;
178
+ provider?: string;
179
+ model?: string;
180
+ }
146
181
  interface UsageCounters {
147
182
  turns: number;
148
183
  injectedCharacters: number;
@@ -150,22 +185,42 @@ interface UsageCounters {
150
185
  providerTokens: number;
151
186
  cacheReadTokens: number;
152
187
  cacheWriteTokens: number;
188
+ toolCalls: number;
189
+ toolFailures: number;
190
+ toolClasses: Map<string, number>;
191
+ acceptedOutcomes: number;
192
+ rejectedOutcomes: number;
153
193
  }
154
194
 
155
195
  function emptyCounters(): UsageCounters {
156
- return { turns: 0, injectedCharacters: 0, estimatedInjectedTokens: 0, providerTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
196
+ return {
197
+ turns: 0,
198
+ injectedCharacters: 0,
199
+ estimatedInjectedTokens: 0,
200
+ providerTokens: 0,
201
+ cacheReadTokens: 0,
202
+ cacheWriteTokens: 0,
203
+ toolCalls: 0,
204
+ toolFailures: 0,
205
+ toolClasses: new Map(),
206
+ acceptedOutcomes: 0,
207
+ rejectedOutcomes: 0,
208
+ };
157
209
  }
158
210
 
159
211
  export class CompactionTelemetry {
160
212
  private open: OpenCompaction | undefined;
161
213
  private counters = emptyCounters();
162
214
  private previousCompletedAt: number | undefined;
215
+ private pendingEffectiveness: PendingEffectiveness | undefined;
216
+ private regrowth: RegrowthState | undefined;
163
217
 
164
218
  hasOpenCompaction(): boolean {
165
219
  return this.open !== undefined;
166
220
  }
167
221
  observeTurn(): void {
168
222
  this.counters.turns += 1;
223
+ if (this.regrowth) this.regrowth.turns += 1;
169
224
  }
170
225
  observeInjection(characters: number, estimatedTokens: number): void {
171
226
  this.counters.injectedCharacters += Math.max(0, characters);
@@ -176,9 +231,31 @@ export class CompactionTelemetry {
176
231
  this.counters.cacheReadTokens += Math.max(0, usage.cacheRead);
177
232
  this.counters.cacheWriteTokens += Math.max(0, usage.cacheWrite);
178
233
  }
234
+ observeToolClass(toolClass: string, failed: boolean): void {
235
+ if (!/^[a-z][a-z0-9-]{0,31}$/.test(toolClass)) return;
236
+ this.counters.toolCalls += 1;
237
+ if (failed) this.counters.toolFailures += 1;
238
+ if (this.counters.toolClasses.size < 16 || this.counters.toolClasses.has(toolClass))
239
+ this.counters.toolClasses.set(toolClass, (this.counters.toolClasses.get(toolClass) ?? 0) + 1);
240
+ }
241
+ observeOutcome(outcome: "accepted" | "rejected"): void {
242
+ if (outcome === "accepted") this.counters.acceptedOutcomes += 1;
243
+ else this.counters.rejectedOutcomes += 1;
244
+ }
179
245
 
180
246
  begin(input: CompactionStart, now = Date.now()): MetricObservation {
181
- this.open = { ...input, startedAt: now };
247
+ if (this.open) {
248
+ return {
249
+ source: "pi-context",
250
+ scope: "compaction",
251
+ metric: "compaction-overlap",
252
+ value: 1,
253
+ unit: "count",
254
+ observedAt: now,
255
+ attributes: { reason: input.reason, willRetry: input.willRetry, openReason: this.open.reason },
256
+ };
257
+ }
258
+ this.open = { ...input, mechanism: input.mechanism ?? "pi-native", startedAt: now };
182
259
  return {
183
260
  source: "pi-context",
184
261
  scope: "compaction",
@@ -190,7 +267,7 @@ export class CompactionTelemetry {
190
267
  };
191
268
  }
192
269
 
193
- complete(input: Pick<CompactionStart, "reason" | "willRetry">, now = Date.now()): MetricObservation {
270
+ complete(input: CompactionCompletion, now = Date.now()): MetricObservation {
194
271
  if (!this.open)
195
272
  return {
196
273
  source: "pi-context",
@@ -206,6 +283,18 @@ export class CompactionTelemetry {
206
283
  const attributes = this.intervalAttributes(open, now);
207
284
  this.previousCompletedAt = now;
208
285
  this.counters = emptyCounters();
286
+ if (typeof open.contextTokens === "number" && Number.isSafeInteger(open.contextTokens) && open.contextTokens > 0) {
287
+ this.pendingEffectiveness = {
288
+ preContextTokens: open.contextTokens,
289
+ preContextProvenance: open.contextProvenance ?? "provider-reported",
290
+ mechanism: input.mechanism ?? open.mechanism,
291
+ ...(open.provider === undefined ? {} : { provider: open.provider }),
292
+ ...(open.model === undefined ? {} : { model: open.model }),
293
+ ...(input.summaryTokens === undefined ? {} : { summaryTokens: Math.max(0, Math.round(input.summaryTokens)) }),
294
+ ...(input.summaryProvenance === undefined ? {} : { summaryProvenance: input.summaryProvenance }),
295
+ completedAt: now,
296
+ };
297
+ }
209
298
  return {
210
299
  source: "pi-context",
211
300
  scope: "compaction",
@@ -213,10 +302,108 @@ export class CompactionTelemetry {
213
302
  value: Math.max(0, now - open.startedAt),
214
303
  unit: "milliseconds",
215
304
  observedAt: now,
216
- attributes: { ...attributes, reason: input.reason, willRetry: input.willRetry },
305
+ attributes: {
306
+ ...attributes,
307
+ reason: input.reason,
308
+ willRetry: input.willRetry,
309
+ mechanism: input.mechanism ?? open.mechanism,
310
+ ...(input.summaryTokens === undefined ? {} : { summaryTokens: Math.max(0, Math.round(input.summaryTokens)) }),
311
+ ...(input.summaryProvenance === undefined ? {} : { summaryProvenance: input.summaryProvenance }),
312
+ },
217
313
  };
218
314
  }
219
315
 
316
+ /** Correlates the first post-compaction request, then reports bounded 50/80/100% regrowth milestones once each. */
317
+ observeContextSnapshot(
318
+ tokens: number,
319
+ provenance: TokenMeasurementProvenance,
320
+ now = Date.now(),
321
+ identity?: { provider: string; model: string },
322
+ ): MetricObservation[] {
323
+ if (!Number.isSafeInteger(tokens) || tokens < 0) return [];
324
+ const observations: MetricObservation[] = [];
325
+ if (this.pendingEffectiveness) {
326
+ const pending = this.pendingEffectiveness;
327
+ this.pendingEffectiveness = undefined;
328
+ const identityChange =
329
+ identity && pending.provider && identity.provider !== pending.provider
330
+ ? "provider-changed"
331
+ : identity && pending.model && identity.model !== pending.model
332
+ ? "model-changed"
333
+ : null;
334
+ if (identityChange) {
335
+ this.regrowth = undefined;
336
+ return [
337
+ {
338
+ source: "pi-context",
339
+ scope: "compaction",
340
+ metric: "compaction-effectiveness-unavailable",
341
+ value: 1,
342
+ unit: "count",
343
+ observedAt: now,
344
+ attributes: { reason: identityChange, mechanism: pending.mechanism },
345
+ },
346
+ ];
347
+ }
348
+ const reduction = (pending.preContextTokens - tokens) / pending.preContextTokens;
349
+ observations.push({
350
+ source: "pi-context",
351
+ scope: "compaction",
352
+ metric: "compaction-effectiveness",
353
+ value: reduction,
354
+ unit: "ratio",
355
+ observedAt: now,
356
+ attributes: {
357
+ mechanism: pending.mechanism,
358
+ preContextTokens: pending.preContextTokens,
359
+ postContextTokens: tokens,
360
+ preContextProvenance: pending.preContextProvenance,
361
+ postContextProvenance: provenance,
362
+ ...(pending.provider === undefined ? {} : { provider: pending.provider }),
363
+ ...(pending.model === undefined ? {} : { model: pending.model }),
364
+ ...(pending.summaryTokens === undefined ? {} : { summaryTokens: pending.summaryTokens }),
365
+ ...(pending.summaryProvenance === undefined ? {} : { summaryProvenance: pending.summaryProvenance }),
366
+ },
367
+ });
368
+ this.regrowth = {
369
+ preContextTokens: pending.preContextTokens,
370
+ completedAt: pending.completedAt,
371
+ turns: 0,
372
+ emitted: new Set(),
373
+ mechanism: pending.mechanism,
374
+ ...(pending.provider === undefined ? {} : { provider: pending.provider }),
375
+ ...(pending.model === undefined ? {} : { model: pending.model }),
376
+ };
377
+ return observations;
378
+ }
379
+ const regrowth = this.regrowth;
380
+ if (!regrowth) return observations;
381
+ const fraction = tokens / regrowth.preContextTokens;
382
+ for (const threshold of [0.5, 0.8, 1]) {
383
+ if (fraction < threshold || regrowth.emitted.has(threshold)) continue;
384
+ regrowth.emitted.add(threshold);
385
+ observations.push({
386
+ source: "pi-context",
387
+ scope: "compaction",
388
+ metric: "compaction-regrowth",
389
+ value: threshold,
390
+ unit: "ratio",
391
+ observedAt: now,
392
+ attributes: {
393
+ threshold,
394
+ contextTokens: tokens,
395
+ contextProvenance: provenance,
396
+ turnsSinceCompaction: regrowth.turns,
397
+ elapsedSinceCompactionMs: Math.max(0, now - regrowth.completedAt),
398
+ mechanism: regrowth.mechanism,
399
+ ...(regrowth.provider === undefined ? {} : { provider: regrowth.provider }),
400
+ ...(regrowth.model === undefined ? {} : { model: regrowth.model }),
401
+ },
402
+ });
403
+ }
404
+ return observations;
405
+ }
406
+
220
407
  abort(now = Date.now(), abortReason = "aborted"): MetricObservation {
221
408
  const open = this.open;
222
409
  this.open = undefined;
@@ -247,6 +434,10 @@ export class CompactionTelemetry {
247
434
  return {
248
435
  reason: open.reason,
249
436
  willRetry: open.willRetry,
437
+ mechanism: open.mechanism,
438
+ ...(open.contextProvenance === undefined ? {} : { contextProvenance: open.contextProvenance }),
439
+ ...(open.provider === undefined ? {} : { provider: open.provider }),
440
+ ...(open.model === undefined ? {} : { model: open.model }),
250
441
  ...(open.contextPercent === undefined ? {} : { contextPercent: open.contextPercent }),
251
442
  ...(open.contextTokens === undefined ? {} : { contextTokens: open.contextTokens }),
252
443
  turnsSincePrevious: this.counters.turns,
@@ -255,6 +446,13 @@ export class CompactionTelemetry {
255
446
  providerTokensSincePrevious: this.counters.providerTokens,
256
447
  cacheReadTokensSincePrevious: this.counters.cacheReadTokens,
257
448
  cacheWriteTokensSincePrevious: this.counters.cacheWriteTokens,
449
+ toolCallsSincePrevious: this.counters.toolCalls,
450
+ toolFailuresSincePrevious: this.counters.toolFailures,
451
+ repeatedToolClassesSincePrevious: [...this.counters.toolClasses.entries()]
452
+ .filter(([, count]) => count > 1)
453
+ .map(([name, count]) => `${name}:${count}`),
454
+ acceptedOutcomesSincePrevious: this.counters.acceptedOutcomes,
455
+ rejectedOutcomesSincePrevious: this.counters.rejectedOutcomes,
258
456
  ...(this.previousCompletedAt === undefined ? {} : { elapsedSincePreviousMs: Math.max(0, now - this.previousCompletedAt) }),
259
457
  };
260
458
  }
@@ -285,6 +483,12 @@ export interface ContextAssessment {
285
483
  averageElapsedMsBetween: number | null;
286
484
  averageProviderTokensBetween: number | null;
287
485
  averageCacheReadTokensBetween: number | null;
486
+ effectivenessSamples: number;
487
+ averageReductionRatio: number | null;
488
+ averagePreContextTokens: number | null;
489
+ averagePostContextTokens: number | null;
490
+ mechanisms: Record<CompactionMechanism, number>;
491
+ regrowth: Record<"50" | "80" | "100", { samples: number; averageTurns: number | null; averageElapsedMs: number | null } | null>;
288
492
  reasons: Record<"manual" | "threshold" | "overflow", number>;
289
493
  };
290
494
  }
@@ -315,6 +519,24 @@ export function assessContextTelemetry(
315
519
  const unchanged = injections.filter((row) => row.attributes.unchanged === true).length;
316
520
  const completed = compactions.filter((row) => row.metric === "compaction-duration" && typeof row.value === "number");
317
521
  const aborted = compactions.filter((row) => row.metric === "compaction-aborted");
522
+ const effectiveness = compactions.filter(
523
+ (row) => row.metric === "compaction-effectiveness" && typeof row.value === "number" && Number.isFinite(row.value),
524
+ );
525
+ const regrowthRows = compactions.filter((row) => row.metric === "compaction-regrowth");
526
+ const mechanisms: Record<CompactionMechanism, number> = { "pi-native": 0, "provider-side": 0, extension: 0 };
527
+ for (const row of effectiveness) {
528
+ const mechanism = row.attributes.mechanism;
529
+ if (mechanism === "pi-native" || mechanism === "provider-side" || mechanism === "extension") mechanisms[mechanism] += 1;
530
+ }
531
+ const regrowth = (threshold: number): { samples: number; averageTurns: number | null; averageElapsedMs: number | null } | null => {
532
+ const rows = regrowthRows.filter((row) => row.attributes.threshold === threshold);
533
+ if (rows.length === 0) return null;
534
+ return {
535
+ samples: rows.length,
536
+ averageTurns: average(rows.flatMap((row) => numericAttribute(row, "turnsSinceCompaction") ?? [])),
537
+ averageElapsedMs: average(rows.flatMap((row) => numericAttribute(row, "elapsedSinceCompactionMs") ?? [])),
538
+ };
539
+ };
318
540
  const reasons = { manual: 0, threshold: 0, overflow: 0 };
319
541
  for (const row of completed) {
320
542
  const reason = row.attributes.reason;
@@ -347,6 +569,12 @@ export function assessContextTelemetry(
347
569
  averageElapsedMsBetween: average(completed.flatMap((row) => numericAttribute(row, "elapsedSincePreviousMs") ?? [])),
348
570
  averageProviderTokensBetween: average(completed.flatMap((row) => numericAttribute(row, "providerTokensSincePrevious") ?? [])),
349
571
  averageCacheReadTokensBetween: average(completed.flatMap((row) => numericAttribute(row, "cacheReadTokensSincePrevious") ?? [])),
572
+ effectivenessSamples: effectiveness.length,
573
+ averageReductionRatio: average(effectiveness.map((row) => row.value as number)),
574
+ averagePreContextTokens: average(effectiveness.flatMap((row) => numericAttribute(row, "preContextTokens") ?? [])),
575
+ averagePostContextTokens: average(effectiveness.flatMap((row) => numericAttribute(row, "postContextTokens") ?? [])),
576
+ mechanisms,
577
+ regrowth: { "50": regrowth(0.5), "80": regrowth(0.8), "100": regrowth(1) },
350
578
  reasons,
351
579
  },
352
580
  };
@@ -0,0 +1,42 @@
1
+ import { BENCHMARK_IDENTITY_MAX_CHARACTERS } from "../constants.ts";
2
+
3
+ const VERSION_SUFFIX = /-(\d{4}-\d{2}-\d{2})$/;
4
+
5
+ export interface ModelIdentity {
6
+ provider: string;
7
+ model: string;
8
+ version: string | null;
9
+ canonical: string;
10
+ aliases: string[];
11
+ }
12
+
13
+ function identityText(value: string, name: string): string {
14
+ if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${name} is required`);
15
+ const normalized = value.trim().toLowerCase();
16
+ if (normalized.length > BENCHMARK_IDENTITY_MAX_CHARACTERS) throw new Error(`${name} exceeds the length limit`);
17
+ if (/\p{Cc}/u.test(normalized)) throw new Error(`${name} contains control characters`);
18
+ return normalized;
19
+ }
20
+
21
+ function identityPart(value: string, name: string, allowPath = false): string {
22
+ const normalized = identityText(value, name);
23
+ const pattern = allowPath ? /^[a-z0-9][a-z0-9._:+/-]*$/ : /^[a-z0-9][a-z0-9._:+-]*$/;
24
+ if (!pattern.test(normalized) || normalized.includes("//")) throw new Error(`${name} is invalid`);
25
+ return normalized;
26
+ }
27
+
28
+ export function normalizeModelIdentity(provider: string, model: string, aliases: string[] = []): ModelIdentity {
29
+ const normalizedProvider = identityPart(provider, "provider");
30
+ const normalizedModel = identityPart(model, "model", true);
31
+ const canonical = `${normalizedProvider}/${normalizedModel}`;
32
+ const normalizedAliases = [...new Set(aliases.map((alias) => identityText(alias, "alias")))]
33
+ .filter((alias) => alias !== canonical)
34
+ .sort();
35
+ return {
36
+ provider: normalizedProvider,
37
+ model: normalizedModel,
38
+ version: VERSION_SUFFIX.exec(normalizedModel)?.[1] ?? null,
39
+ canonical,
40
+ aliases: normalizedAliases,
41
+ };
42
+ }
@@ -4,8 +4,9 @@ import {
4
4
  MODEL_OBSERVATION_FRESH_MS,
5
5
  MODEL_OBSERVATION_IDENTITY_MAX_CHARACTERS,
6
6
  } from "../constants.ts";
7
- import { normalizeModelIdentity } from "./benchmark.ts";
8
7
  import type { MetricObservation, MetricUnit, StoredMetricObservation } from "./metric.ts";
8
+ import { normalizeModelIdentity } from "./model-identity.ts";
9
+ import type { TokenMeasurementScope } from "./token-measurement.ts";
9
10
 
10
11
  /**
11
12
  * Two independent axes, not one flat class: "coding" is a subject-matter domain (which
@@ -131,10 +132,10 @@ export function validateModelRunObservation(value: unknown): ModelRunObservation
131
132
  startedAt,
132
133
  firstTokenAt,
133
134
  completedAt,
134
- inputTokens: nonNegative(input.inputTokens, "input tokens"),
135
- outputTokens: nonNegative(input.outputTokens, "output tokens"),
136
- cacheReadTokens: nonNegative(input.cacheReadTokens, "cache read tokens"),
137
- cacheWriteTokens: nonNegative(input.cacheWriteTokens, "cache write tokens"),
135
+ inputTokens: nonNegative(input.inputTokens, "input tokens", true),
136
+ outputTokens: nonNegative(input.outputTokens, "output tokens", true),
137
+ cacheReadTokens: nonNegative(input.cacheReadTokens, "cache read tokens", true),
138
+ cacheWriteTokens: nonNegative(input.cacheWriteTokens, "cache write tokens", true),
138
139
  costUsd: nonNegative(input.costUsd, "cost"),
139
140
  providerResponses,
140
141
  toolCalls,
@@ -174,14 +175,28 @@ export function modelRunMetrics(value: ModelRunObservation): MetricObservation[]
174
175
  type: run.type,
175
176
  runId: run.runId,
176
177
  };
177
- const metric = (name: string, amount: number, unit: MetricUnit): MetricObservation => ({
178
+ const metric = (name: string, amount: number, unit: MetricUnit, tokenScope?: TokenMeasurementScope): MetricObservation => ({
178
179
  source: "local-model",
179
180
  scope,
180
181
  metric: name,
181
182
  value: amount,
182
183
  unit,
183
184
  observedAt: run.completedAt,
184
- attributes,
185
+ attributes: {
186
+ ...attributes,
187
+ ...(tokenScope
188
+ ? {
189
+ tokenMeasurement: {
190
+ tokens: amount,
191
+ scope: tokenScope,
192
+ provenance: "provider-reported",
193
+ method: "pi-assistant-usage",
194
+ provider: run.provider,
195
+ model: run.model,
196
+ },
197
+ }
198
+ : {}),
199
+ },
185
200
  });
186
201
  const wallMs = run.completedAt - run.startedAt;
187
202
  const totalInput = run.inputTokens + run.cacheReadTokens;
@@ -190,10 +205,10 @@ export function modelRunMetrics(value: ModelRunObservation): MetricObservation[]
190
205
  metrics.push(
191
206
  metric("wall-latency", wallMs, "milliseconds"),
192
207
  metric("output-throughput", wallMs === 0 ? 0 : run.outputTokens / (wallMs / 1_000), "tokens-per-second"),
193
- metric("input-tokens", run.inputTokens, "tokens"),
194
- metric("output-tokens", run.outputTokens, "tokens"),
195
- metric("cache-read-tokens", run.cacheReadTokens, "tokens"),
196
- metric("cache-write-tokens", run.cacheWriteTokens, "tokens"),
208
+ metric("input-tokens", run.inputTokens, "tokens", "request-input"),
209
+ metric("output-tokens", run.outputTokens, "tokens", "response-output"),
210
+ metric("cache-read-tokens", run.cacheReadTokens, "tokens", "cache-read"),
211
+ metric("cache-write-tokens", run.cacheWriteTokens, "tokens", "cache-write"),
197
212
  metric("cache-read-ratio", totalInput === 0 ? 0 : run.cacheReadTokens / totalInput, "ratio"),
198
213
  metric("cost", run.costUsd, "usd"),
199
214
  metric("provider-responses", run.providerResponses, "count"),
@@ -1,5 +1,5 @@
1
- import type { MetricObservation, MetricQuery, StoredMetricObservation } from "../domain/metric.ts";
2
- import type { UsageAggregateRow } from "../domain/usage.ts";
1
+ import type { MetricObservation, MetricQuery, StoredMetricObservation } from "./metric.ts";
2
+ import type { UsageAggregateRow } from "./usage.ts";
3
3
 
4
4
  export interface DistinctScopesFilter {
5
5
  source: string;
@@ -1,5 +1,5 @@
1
- import type { MetricObservation } from "../domain/metric.ts";
2
- import type { BudgetWindow } from "../policy.ts";
1
+ import type { BudgetWindow } from "./budget.ts";
2
+ import type { MetricObservation } from "./metric.ts";
3
3
 
4
4
  export interface TelemetryBatch {
5
5
  observedAt: number;
@@ -0,0 +1,194 @@
1
+ import { CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN } from "../constants.ts";
2
+ import { normalizeModelIdentity } from "./model-identity.ts";
3
+
4
+ export const TOKEN_MEASUREMENT_PROVENANCES = [
5
+ "provider-reported",
6
+ "provider-count-api",
7
+ "tokenizer-exact-text",
8
+ "structural-estimate",
9
+ ] as const;
10
+ export type TokenMeasurementProvenance = (typeof TOKEN_MEASUREMENT_PROVENANCES)[number];
11
+
12
+ export const TOKEN_MEASUREMENT_SCOPES = [
13
+ "request-context",
14
+ "request-input",
15
+ "response-output",
16
+ "cache-read",
17
+ "cache-write",
18
+ "context-item",
19
+ "unattributed-residual",
20
+ ] as const;
21
+ export type TokenMeasurementScope = (typeof TOKEN_MEASUREMENT_SCOPES)[number];
22
+
23
+ export interface TokenMeasurement {
24
+ tokens: number;
25
+ scope: TokenMeasurementScope;
26
+ provenance: TokenMeasurementProvenance;
27
+ /** Bounded implementation/counting identity, never content (for example `char/4` or `gpt-tokenizer:o200k_base`). */
28
+ method: string;
29
+ provider?: string;
30
+ model?: string;
31
+ }
32
+
33
+ const TOKEN_MEASUREMENT_FIELDS = new Set<keyof TokenMeasurement>(["tokens", "scope", "provenance", "method", "provider", "model"]);
34
+ const TOKEN_MEASUREMENT_METHOD_MAX_CHARACTERS = 120;
35
+
36
+ function boundedMethod(value: unknown): string {
37
+ if (typeof value !== "string" || value.length === 0 || value.length > TOKEN_MEASUREMENT_METHOD_MAX_CHARACTERS || /\p{Cc}/u.test(value))
38
+ throw new Error("token measurement method is invalid");
39
+ return value;
40
+ }
41
+
42
+ export function validateTokenMeasurement(value: unknown): TokenMeasurement {
43
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("token measurement must be an object");
44
+ const input = value as Record<string, unknown>;
45
+ for (const key of Object.keys(input)) {
46
+ if (!TOKEN_MEASUREMENT_FIELDS.has(key as keyof TokenMeasurement)) throw new Error(`unsupported field: ${key}`);
47
+ }
48
+ if (typeof input.tokens !== "number" || !Number.isSafeInteger(input.tokens) || input.tokens < 0)
49
+ throw new Error("token measurement tokens are invalid");
50
+ if (!TOKEN_MEASUREMENT_SCOPES.includes(input.scope as TokenMeasurementScope)) throw new Error("token measurement scope is invalid");
51
+ if (!TOKEN_MEASUREMENT_PROVENANCES.includes(input.provenance as TokenMeasurementProvenance))
52
+ throw new Error("token measurement provenance is invalid");
53
+ if ((input.provider === undefined) !== (input.model === undefined))
54
+ throw new Error("token measurement provider/model identity is incomplete");
55
+ if (
56
+ (input.provider !== undefined && typeof input.provider !== "string") ||
57
+ (input.model !== undefined && typeof input.model !== "string")
58
+ )
59
+ throw new Error("token measurement provider/model identity is invalid");
60
+ const identity =
61
+ input.provider === undefined || input.model === undefined ? undefined : normalizeModelIdentity(input.provider, input.model);
62
+ return {
63
+ tokens: input.tokens,
64
+ scope: input.scope as TokenMeasurementScope,
65
+ provenance: input.provenance as TokenMeasurementProvenance,
66
+ method: boundedMethod(input.method),
67
+ ...(identity ? { provider: identity.provider, model: identity.model } : {}),
68
+ };
69
+ }
70
+
71
+ export interface TextTokenCountInput {
72
+ /** Ephemeral input. Implementations must not retain or include it in diagnostics/measurements. */
73
+ text: string;
74
+ provider?: string;
75
+ model?: string;
76
+ scope: "context-item" | "request-input" | "response-output";
77
+ }
78
+
79
+ /** A replaceable, content-ephemeral text counting boundary. Unsupported provider/model identities return null. */
80
+ export interface TextTokenCounter {
81
+ countText(input: TextTokenCountInput): TokenMeasurement | null;
82
+ }
83
+
84
+ export class StructuralTextTokenCounter implements TextTokenCounter {
85
+ countText(input: TextTokenCountInput): TokenMeasurement {
86
+ const identity =
87
+ input.provider === undefined || input.model === undefined ? undefined : normalizeModelIdentity(input.provider, input.model);
88
+ return {
89
+ tokens: Math.ceil(input.text.length / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
90
+ scope: input.scope,
91
+ provenance: "structural-estimate",
92
+ method: `char/${CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN}`,
93
+ ...(identity ? { provider: identity.provider, model: identity.model } : {}),
94
+ };
95
+ }
96
+ }
97
+
98
+ /** Uses the first supporting model-aware counter and fails open to the explicit structural fallback. */
99
+ export function countTextWithFallback(
100
+ input: TextTokenCountInput,
101
+ counters: readonly TextTokenCounter[],
102
+ fallback: TextTokenCounter = new StructuralTextTokenCounter(),
103
+ ): TokenMeasurement {
104
+ for (const counter of counters) {
105
+ try {
106
+ const measurement = counter.countText(input);
107
+ if (measurement) return validateTokenMeasurement(measurement);
108
+ } catch {
109
+ // Counting is observational. Unknown mappings/tokenizer failures must remain visibly estimated, never block the request.
110
+ }
111
+ }
112
+ const measurement = fallback.countText(input);
113
+ if (!measurement) throw new Error("structural token counter did not return a measurement");
114
+ return validateTokenMeasurement(measurement);
115
+ }
116
+
117
+ export interface RequestTokenReconciliation {
118
+ aggregate: TokenMeasurement;
119
+ attributedTokens: number;
120
+ residual: TokenMeasurement;
121
+ overshootTokens: number;
122
+ }
123
+
124
+ const REQUEST_RECONCILIATION_FIELDS = new Set<keyof RequestTokenReconciliation>([
125
+ "aggregate",
126
+ "attributedTokens",
127
+ "residual",
128
+ "overshootTokens",
129
+ ]);
130
+
131
+ export function validateRequestTokenReconciliation(value: unknown): RequestTokenReconciliation {
132
+ if (typeof value !== "object" || value === null || Array.isArray(value))
133
+ throw new Error("request token reconciliation must be an object");
134
+ const input = value as Record<string, unknown>;
135
+ for (const key of Object.keys(input)) {
136
+ if (!REQUEST_RECONCILIATION_FIELDS.has(key as keyof RequestTokenReconciliation))
137
+ throw new Error(`request token reconciliation contains unsupported field: ${key}`);
138
+ }
139
+ const aggregate = validateTokenMeasurement(input.aggregate);
140
+ const residual = validateTokenMeasurement(input.residual);
141
+ if (aggregate.scope !== "request-context" && aggregate.scope !== "request-input")
142
+ throw new Error("request token aggregate scope is invalid");
143
+ if (
144
+ residual.scope !== "unattributed-residual" ||
145
+ residual.provenance !== "structural-estimate" ||
146
+ residual.method !== "aggregate-minus-attributed"
147
+ )
148
+ throw new Error("request token residual provenance is invalid");
149
+ if (residual.provider !== aggregate.provider || residual.model !== aggregate.model)
150
+ throw new Error("request token residual identity does not match aggregate identity");
151
+ const attributedTokens = input.attributedTokens;
152
+ const overshootTokens = input.overshootTokens;
153
+ if (typeof attributedTokens !== "number" || !Number.isSafeInteger(attributedTokens) || attributedTokens < 0)
154
+ throw new Error("request token attributed count is invalid");
155
+ if (typeof overshootTokens !== "number" || !Number.isSafeInteger(overshootTokens) || overshootTokens < 0)
156
+ throw new Error("request token overshoot count is invalid");
157
+ if (residual.tokens !== Math.max(0, aggregate.tokens - attributedTokens)) throw new Error("request token residual is inconsistent");
158
+ if (overshootTokens !== Math.max(0, attributedTokens - aggregate.tokens)) throw new Error("request token overshoot is inconsistent");
159
+ return { aggregate, attributedTokens, residual, overshootTokens };
160
+ }
161
+
162
+ /**
163
+ * Reconciles local item measurements against an authoritative request aggregate without spreading
164
+ * its remainder across those items. The residual stays explicit and estimate-provenance because
165
+ * provider envelope/media/rewrites and mixed local counting methods prevent exact attribution.
166
+ */
167
+ export function reconcileRequestTokens(
168
+ aggregateValue: TokenMeasurement,
169
+ attributedValues: readonly TokenMeasurement[],
170
+ ): RequestTokenReconciliation {
171
+ const aggregate = validateTokenMeasurement(aggregateValue);
172
+ if (aggregate.scope !== "request-context" && aggregate.scope !== "request-input")
173
+ throw new Error("request token aggregate scope is invalid");
174
+ const attributed = attributedValues.map(validateTokenMeasurement);
175
+ if (attributed.some((measurement) => measurement.scope !== "context-item")) throw new Error("request token attribution scope is invalid");
176
+ const attributedTokens = attributed.reduce((sum, measurement) => {
177
+ const next = sum + measurement.tokens;
178
+ if (!Number.isSafeInteger(next)) throw new Error("request token attribution sum is invalid");
179
+ return next;
180
+ }, 0);
181
+ const difference = aggregate.tokens - attributedTokens;
182
+ return {
183
+ aggregate,
184
+ attributedTokens,
185
+ residual: {
186
+ tokens: Math.max(0, difference),
187
+ scope: "unattributed-residual",
188
+ provenance: "structural-estimate",
189
+ method: "aggregate-minus-attributed",
190
+ ...(aggregate.provider && aggregate.model ? { provider: aggregate.provider, model: aggregate.model } : {}),
191
+ },
192
+ overshootTokens: Math.max(0, -difference),
193
+ };
194
+ }