@graphorin/observability 0.6.0 → 0.7.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 (86) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +29 -5
  3. package/dist/cost/cost-tracker.d.ts.map +1 -1
  4. package/dist/cost/cost-tracker.js +34 -0
  5. package/dist/cost/cost-tracker.js.map +1 -1
  6. package/dist/cost/delegate.d.ts +40 -0
  7. package/dist/cost/delegate.d.ts.map +1 -0
  8. package/dist/cost/delegate.js +31 -0
  9. package/dist/cost/delegate.js.map +1 -0
  10. package/dist/cost/index.d.ts +2 -1
  11. package/dist/cost/index.js +2 -1
  12. package/dist/cost/types.d.ts +39 -0
  13. package/dist/cost/types.d.ts.map +1 -1
  14. package/dist/exporters/otlp-http.d.ts.map +1 -1
  15. package/dist/exporters/otlp-http.js +4 -2
  16. package/dist/exporters/otlp-http.js.map +1 -1
  17. package/dist/exporters/types.d.ts +7 -0
  18. package/dist/exporters/types.d.ts.map +1 -1
  19. package/dist/exporters/types.js.map +1 -1
  20. package/dist/exporters/with-validation.d.ts.map +1 -1
  21. package/dist/exporters/with-validation.js +5 -4
  22. package/dist/exporters/with-validation.js.map +1 -1
  23. package/dist/gen-ai/emit.js +9 -1
  24. package/dist/gen-ai/emit.js.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -10
  28. package/dist/index.js.map +1 -1
  29. package/dist/package.js +6 -0
  30. package/dist/package.js.map +1 -0
  31. package/dist/redaction/imperative-patterns.d.ts +3 -3
  32. package/dist/redaction/imperative-patterns.d.ts.map +1 -1
  33. package/dist/redaction/imperative-patterns.js +7 -0
  34. package/dist/redaction/imperative-patterns.js.map +1 -1
  35. package/dist/redaction/patterns.d.ts.map +1 -1
  36. package/dist/redaction/patterns.js +2 -2
  37. package/dist/redaction/patterns.js.map +1 -1
  38. package/dist/tracer/sampling.d.ts +6 -1
  39. package/dist/tracer/sampling.d.ts.map +1 -1
  40. package/dist/tracer/sampling.js +7 -1
  41. package/dist/tracer/sampling.js.map +1 -1
  42. package/dist/tracer/span.d.ts.map +1 -1
  43. package/dist/tracer/span.js +12 -3
  44. package/dist/tracer/span.js.map +1 -1
  45. package/package.json +18 -35
  46. package/src/cost/cost-tracker.ts +315 -0
  47. package/src/cost/delegate.ts +79 -0
  48. package/src/cost/index.ts +23 -0
  49. package/src/cost/types.ts +151 -0
  50. package/src/eval/index.ts +16 -0
  51. package/src/eval/runner.ts +146 -0
  52. package/src/eval/types.ts +122 -0
  53. package/src/exporters/console.ts +75 -0
  54. package/src/exporters/index.ts +36 -0
  55. package/src/exporters/jsonl.ts +167 -0
  56. package/src/exporters/otlp-http.ts +178 -0
  57. package/src/exporters/types.ts +85 -0
  58. package/src/exporters/with-validation.ts +176 -0
  59. package/src/gen-ai/emit.ts +157 -0
  60. package/src/gen-ai/index.ts +26 -0
  61. package/src/gen-ai/operation-mapping.ts +89 -0
  62. package/src/gen-ai/system-derivation.ts +84 -0
  63. package/src/gen-ai/types.ts +143 -0
  64. package/src/index.ts +36 -0
  65. package/src/logger/index.ts +13 -0
  66. package/src/logger/logger.ts +178 -0
  67. package/src/openinference/index.ts +133 -0
  68. package/src/redaction/config.ts +50 -0
  69. package/src/redaction/errors.ts +53 -0
  70. package/src/redaction/imperative-patterns.ts +268 -0
  71. package/src/redaction/index.ts +42 -0
  72. package/src/redaction/patterns.ts +263 -0
  73. package/src/redaction/types.ts +116 -0
  74. package/src/redaction/validator.ts +302 -0
  75. package/src/replay/config.ts +58 -0
  76. package/src/replay/index.ts +17 -0
  77. package/src/replay/log.ts +89 -0
  78. package/src/replay/replay.ts +196 -0
  79. package/src/replay/types.ts +112 -0
  80. package/src/telemetry/index.ts +94 -0
  81. package/src/tracer/ids.ts +27 -0
  82. package/src/tracer/index.ts +22 -0
  83. package/src/tracer/sampling.ts +170 -0
  84. package/src/tracer/span-names.ts +52 -0
  85. package/src/tracer/span.ts +175 -0
  86. package/src/tracer/tracer.ts +309 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graphorin/observability",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Observability primitives for the Graphorin framework: typed AISpan tracer over OpenTelemetry, mandatory withValidation wrapper for every exporter, sensitivity-aware RedactionValidator with built-in PII / secret patterns, GenAI Semantic Conventions conformance helpers, OpenInference span-kind emission, structured logger with span correlation, append-only JSONL exporter for replay, sanitized replay primitives, hierarchical CostTracker, and a minimal inline eval runner alongside a zero-default telemetry stub.",
5
5
  "license": "MIT",
6
6
  "author": "Oleksiy Stepurenko",
@@ -32,8 +32,9 @@
32
32
  "logger"
33
33
  ],
34
34
  "type": "module",
35
+ "sideEffects": false,
35
36
  "engines": {
36
- "node": ">=22.0.0"
37
+ "node": ">=22.12.0"
37
38
  },
38
39
  "main": "./dist/index.js",
39
40
  "module": "./dist/index.js",
@@ -41,91 +42,73 @@
41
42
  "exports": {
42
43
  ".": {
43
44
  "types": "./dist/index.d.ts",
44
- "import": "./dist/index.js"
45
+ "default": "./dist/index.js"
45
46
  },
46
47
  "./tracer": {
47
48
  "types": "./dist/tracer/index.d.ts",
48
- "import": "./dist/tracer/index.js"
49
+ "default": "./dist/tracer/index.js"
49
50
  },
50
51
  "./redaction": {
51
52
  "types": "./dist/redaction/index.d.ts",
52
- "import": "./dist/redaction/index.js"
53
+ "default": "./dist/redaction/index.js"
53
54
  },
54
55
  "./redaction/patterns": {
55
56
  "types": "./dist/redaction/patterns.d.ts",
56
- "import": "./dist/redaction/patterns.js"
57
+ "default": "./dist/redaction/patterns.js"
57
58
  },
58
59
  "./redaction/imperative-patterns": {
59
60
  "types": "./dist/redaction/imperative-patterns.d.ts",
60
- "import": "./dist/redaction/imperative-patterns.js"
61
+ "default": "./dist/redaction/imperative-patterns.js"
61
62
  },
62
63
  "./exporters": {
63
64
  "types": "./dist/exporters/index.d.ts",
64
- "import": "./dist/exporters/index.js"
65
+ "default": "./dist/exporters/index.js"
65
66
  },
66
67
  "./gen-ai": {
67
68
  "types": "./dist/gen-ai/index.d.ts",
68
- "import": "./dist/gen-ai/index.js"
69
+ "default": "./dist/gen-ai/index.js"
69
70
  },
70
71
  "./openinference": {
71
72
  "types": "./dist/openinference/index.d.ts",
72
- "import": "./dist/openinference/index.js"
73
+ "default": "./dist/openinference/index.js"
73
74
  },
74
75
  "./cost": {
75
76
  "types": "./dist/cost/index.d.ts",
76
- "import": "./dist/cost/index.js"
77
+ "default": "./dist/cost/index.js"
77
78
  },
78
79
  "./replay": {
79
80
  "types": "./dist/replay/index.d.ts",
80
- "import": "./dist/replay/index.js"
81
+ "default": "./dist/replay/index.js"
81
82
  },
82
83
  "./eval": {
83
84
  "types": "./dist/eval/index.d.ts",
84
- "import": "./dist/eval/index.js"
85
+ "default": "./dist/eval/index.js"
85
86
  },
86
87
  "./logger": {
87
88
  "types": "./dist/logger/index.d.ts",
88
- "import": "./dist/logger/index.js"
89
+ "default": "./dist/logger/index.js"
89
90
  },
90
91
  "./telemetry": {
91
92
  "types": "./dist/telemetry/index.d.ts",
92
- "import": "./dist/telemetry/index.js"
93
+ "default": "./dist/telemetry/index.js"
93
94
  },
94
95
  "./package.json": "./package.json"
95
96
  },
96
97
  "files": [
97
98
  "dist",
99
+ "src",
98
100
  "README.md",
99
101
  "CHANGELOG.md",
100
102
  "LICENSE"
101
103
  ],
102
104
  "dependencies": {
103
- "@graphorin/core": "0.6.0"
104
- },
105
- "peerDependencies": {
106
- "@opentelemetry/api": "^1.9.0",
107
- "@opentelemetry/sdk-node": "^0.217.0",
108
- "@opentelemetry/exporter-trace-otlp-http": "^0.215.0"
109
- },
110
- "peerDependenciesMeta": {
111
- "@opentelemetry/api": {
112
- "optional": false
113
- },
114
- "@opentelemetry/sdk-node": {
115
- "optional": true
116
- },
117
- "@opentelemetry/exporter-trace-otlp-http": {
118
- "optional": true
119
- }
105
+ "@graphorin/core": "0.7.0"
120
106
  },
121
107
  "publishConfig": {
122
108
  "access": "public",
123
109
  "provenance": true
124
110
  },
125
111
  "devDependencies": {
126
- "@opentelemetry/api": "^1.9.0",
127
- "@opentelemetry/exporter-trace-otlp-http": "^0.215.0",
128
- "@opentelemetry/sdk-node": "^0.217.0",
129
112
  "fast-check": "^3.23.0"
130
113
  },
131
114
  "scripts": {
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `createCostTracker(...)` - aggregate token + cost figures across
3
+ * spans and provide per-scope queries.
4
+ *
5
+ * The tracker preserves parent-child relationships: when
6
+ * `record({ parentSpanId })` is supplied, the recorded usage is also
7
+ * attributed to the ancestor span via the configured spans-by-id
8
+ * lookup. Aggregations across runs / sessions / agents / users are
9
+ * computed on demand.
10
+ *
11
+ * @packageDocumentation
12
+ */
13
+
14
+ import type { Cost } from '@graphorin/core';
15
+
16
+ import type {
17
+ CostBudgetExceededCallback,
18
+ CostBudgets,
19
+ CostRecordInput,
20
+ CostScope,
21
+ CostSnapshot,
22
+ CostTrackerOptions,
23
+ } from './types.js';
24
+
25
+ /**
26
+ * @stable
27
+ */
28
+ export interface CostTracker {
29
+ /** Record a single LLM-call usage / cost figure. */
30
+ record(input: CostRecordInput): void;
31
+ /** Snapshot for a given scope id. Returns zero figures when unknown. */
32
+ usage(scope: CostScope, id: string): CostSnapshot;
33
+ /** Snapshot for a single span id (carries nested attributions). */
34
+ usageForSpan(spanId: string): CostSnapshot;
35
+ /** Reset every counter back to zero. */
36
+ reset(): void;
37
+ /** Subscribe to per-scope rollup notifications. Returns an unsubscribe. */
38
+ onRollup(listener: (input: CostRecordInput) => void): () => void;
39
+ }
40
+
41
+ interface AggregateBucket {
42
+ promptTokens: number;
43
+ completionTokens: number;
44
+ reasoningTokens: number;
45
+ cachedReadTokens: number;
46
+ cacheWriteTokens: number;
47
+ callCount: number;
48
+ costAmount: number;
49
+ costCurrency: string | null;
50
+ /** RP-22: set when records carrying differing currencies are aggregated. */
51
+ mixedCurrency: boolean;
52
+ byModel: Map<string, AggregateBucket>;
53
+ exceeded: boolean;
54
+ }
55
+
56
+ function freshBucket(): AggregateBucket {
57
+ return {
58
+ promptTokens: 0,
59
+ completionTokens: 0,
60
+ reasoningTokens: 0,
61
+ cachedReadTokens: 0,
62
+ cacheWriteTokens: 0,
63
+ callCount: 0,
64
+ costAmount: 0,
65
+ costCurrency: null,
66
+ mixedCurrency: false,
67
+ byModel: new Map(),
68
+ exceeded: false,
69
+ };
70
+ }
71
+
72
+ // RP-22: accumulate a cost without silently overwriting the currency. The
73
+ // first currency seen is kept; a record in a different currency flags the
74
+ // bucket as `mixedCurrency` so a USD + EUR total is never reported as one
75
+ // clean figure.
76
+ function addCost(bucket: AggregateBucket, cost: Cost): void {
77
+ if (bucket.costCurrency !== null && bucket.costCurrency !== cost.currency) {
78
+ bucket.mixedCurrency = true;
79
+ } else if (bucket.costCurrency === null) {
80
+ bucket.costCurrency = cost.currency;
81
+ }
82
+ bucket.costAmount += cost.amount;
83
+ }
84
+
85
+ function combine(target: AggregateBucket, input: CostRecordInput): void {
86
+ target.promptTokens += input.promptTokens;
87
+ target.completionTokens += input.completionTokens;
88
+ target.reasoningTokens += input.reasoningTokens ?? 0;
89
+ target.cachedReadTokens += input.cachedReadTokens ?? 0;
90
+ target.cacheWriteTokens += input.cacheWriteTokens ?? 0;
91
+ target.callCount += 1;
92
+ if (input.cost !== undefined) addCost(target, input.cost);
93
+ const modelBucket = target.byModel.get(input.model) ?? freshBucket();
94
+ modelBucket.promptTokens += input.promptTokens;
95
+ modelBucket.completionTokens += input.completionTokens;
96
+ modelBucket.reasoningTokens += input.reasoningTokens ?? 0;
97
+ modelBucket.cachedReadTokens += input.cachedReadTokens ?? 0;
98
+ modelBucket.cacheWriteTokens += input.cacheWriteTokens ?? 0;
99
+ modelBucket.callCount += 1;
100
+ if (input.cost !== undefined) addCost(modelBucket, input.cost);
101
+ target.byModel.set(input.model, modelBucket);
102
+ }
103
+
104
+ function snapshotOf(bucket: AggregateBucket): CostSnapshot {
105
+ const cost: Cost | null =
106
+ bucket.costCurrency === null
107
+ ? null
108
+ : { amount: bucket.costAmount, currency: bucket.costCurrency };
109
+ return {
110
+ promptTokens: bucket.promptTokens,
111
+ completionTokens: bucket.completionTokens,
112
+ reasoningTokens: bucket.reasoningTokens,
113
+ cachedReadTokens: bucket.cachedReadTokens,
114
+ cacheWriteTokens: bucket.cacheWriteTokens,
115
+ totalTokens: bucket.promptTokens + bucket.completionTokens + bucket.reasoningTokens,
116
+ callCount: bucket.callCount,
117
+ cost,
118
+ mixedCurrency: bucket.mixedCurrency,
119
+ byModel: [...bucket.byModel.entries()].map(([model, b]) => ({
120
+ model,
121
+ promptTokens: b.promptTokens,
122
+ completionTokens: b.completionTokens,
123
+ reasoningTokens: b.reasoningTokens,
124
+ cachedReadTokens: b.cachedReadTokens,
125
+ cacheWriteTokens: b.cacheWriteTokens,
126
+ callCount: b.callCount,
127
+ cost: b.costCurrency === null ? null : { amount: b.costAmount, currency: b.costCurrency },
128
+ mixedCurrency: b.mixedCurrency,
129
+ })),
130
+ };
131
+ }
132
+
133
+ const ZERO: CostSnapshot = Object.freeze({
134
+ promptTokens: 0,
135
+ completionTokens: 0,
136
+ reasoningTokens: 0,
137
+ cachedReadTokens: 0,
138
+ cacheWriteTokens: 0,
139
+ totalTokens: 0,
140
+ callCount: 0,
141
+ cost: null,
142
+ mixedCurrency: false,
143
+ byModel: [],
144
+ });
145
+
146
+ /**
147
+ * Build a {@link CostTracker} configured with the supplied budgets.
148
+ *
149
+ * @stable
150
+ */
151
+ export function createCostTracker(opts: CostTrackerOptions = {}): CostTracker {
152
+ const budgets: CostBudgets = opts.budgets ?? {};
153
+ const currency = budgets.currency ?? 'USD';
154
+ const onExceed = opts.onExceed;
155
+ // W-092: memory bounds - the tracker lives for the process lifetime,
156
+ // so unbounded maps are a leak in the long-running-assistant scenario.
157
+ const retention = opts.retention;
158
+ const maxSpanEntries =
159
+ retention === false
160
+ ? Number.POSITIVE_INFINITY
161
+ : (retention?.maxSpanEntries ?? DEFAULT_MAX_SPAN_ENTRIES);
162
+ const maxScopeEntries =
163
+ retention === false
164
+ ? Number.POSITIVE_INFINITY
165
+ : (retention?.maxScopeEntries ?? DEFAULT_MAX_SCOPE_ENTRIES);
166
+
167
+ const bySpan = new Map<string, AggregateBucket>();
168
+ const byScope: Record<CostScope, Map<string, AggregateBucket>> = {
169
+ run: new Map(),
170
+ session: new Map(),
171
+ agent: new Map(),
172
+ user: new Map(),
173
+ };
174
+
175
+ // Parent index: child span -> parent span. Used to roll attributions
176
+ // up the ancestor chain on every record call.
177
+ const parents = new Map<string, string>();
178
+ const listeners = new Set<(input: CostRecordInput) => void>();
179
+
180
+ // W-092: evict OLDEST entries (Map insertion order - a re-recorded
181
+ // key keeps its original position on purpose: this is an age bound,
182
+ // not an LRU) once a map exceeds its cap. Evicting a span also drops
183
+ // its parent edge so `parents` cannot outgrow `bySpan`.
184
+ function evictOldest(
185
+ map: Map<string, AggregateBucket>,
186
+ max: number,
187
+ surface: 'span' | CostScope,
188
+ ): void {
189
+ while (map.size > max) {
190
+ const oldest = map.keys().next().value;
191
+ if (oldest === undefined) return;
192
+ map.delete(oldest);
193
+ if (surface === 'span') parents.delete(oldest);
194
+ try {
195
+ opts.onEviction?.({ surface, id: oldest });
196
+ } catch {
197
+ // Observers must never break the tracker.
198
+ }
199
+ }
200
+ }
201
+
202
+ function bump(scope: CostScope, id: string | undefined, input: CostRecordInput): void {
203
+ if (id === undefined) return;
204
+ const map = byScope[scope];
205
+ const bucket = map.get(id) ?? freshBucket();
206
+ combine(bucket, input);
207
+ map.set(id, bucket);
208
+ evictOldest(map, maxScopeEntries, scope);
209
+ enforceBudget(scope, id, bucket, onExceed);
210
+ }
211
+
212
+ function enforceBudget(
213
+ scope: CostScope,
214
+ id: string,
215
+ bucket: AggregateBucket,
216
+ cb?: CostBudgetExceededCallback,
217
+ ): void {
218
+ const limit = pickBudget(budgets, scope);
219
+ if (limit === undefined) return;
220
+ if (bucket.exceeded) return;
221
+ if (bucket.costAmount > limit) {
222
+ bucket.exceeded = true;
223
+ cb?.({
224
+ scope,
225
+ id,
226
+ budget: limit,
227
+ actual: bucket.costAmount,
228
+ currency: bucket.costCurrency ?? currency,
229
+ });
230
+ }
231
+ }
232
+
233
+ return {
234
+ record(input: CostRecordInput): void {
235
+ // Record the raw span figures.
236
+ const spanBucket = bySpan.get(input.spanId) ?? freshBucket();
237
+ combine(spanBucket, input);
238
+ bySpan.set(input.spanId, spanBucket);
239
+
240
+ // Walk the ancestor chain so that the figures roll up to every
241
+ // parent span. The walker breaks if it encounters a cycle.
242
+ if (input.parentSpanId !== undefined) {
243
+ parents.set(input.spanId, input.parentSpanId);
244
+ }
245
+ const seen = new Set<string>();
246
+ let ancestor = parents.get(input.spanId);
247
+ while (ancestor !== undefined && !seen.has(ancestor)) {
248
+ seen.add(ancestor);
249
+ const ancestorBucket = bySpan.get(ancestor) ?? freshBucket();
250
+ combine(ancestorBucket, input);
251
+ bySpan.set(ancestor, ancestorBucket);
252
+ ancestor = parents.get(ancestor);
253
+ }
254
+ // W-092: bound the span map AFTER the rollup walk so the entries
255
+ // this record touched are all present before age eviction runs.
256
+ evictOldest(bySpan, maxSpanEntries, 'span');
257
+
258
+ // Roll up to the requested aggregation scopes.
259
+ bump('run', input.runId, input);
260
+ bump('session', input.sessionId, input);
261
+ bump('agent', input.agentId, input);
262
+ bump('user', input.userId, input);
263
+
264
+ for (const listener of listeners) {
265
+ try {
266
+ listener(input);
267
+ } catch {
268
+ // Listeners must never break the tracker.
269
+ }
270
+ }
271
+ },
272
+ // W-092: an id evicted by the retention bound reports ZERO - same
273
+ // as never-seen. Pass `retention: false` for the old unbounded maps.
274
+ usage(scope: CostScope, id: string): CostSnapshot {
275
+ const bucket = byScope[scope].get(id);
276
+ if (bucket === undefined) return ZERO;
277
+ return snapshotOf(bucket);
278
+ },
279
+ usageForSpan(spanId: string): CostSnapshot {
280
+ const bucket = bySpan.get(spanId);
281
+ if (bucket === undefined) return ZERO;
282
+ return snapshotOf(bucket);
283
+ },
284
+ reset(): void {
285
+ bySpan.clear();
286
+ for (const map of Object.values(byScope)) {
287
+ map.clear();
288
+ }
289
+ parents.clear();
290
+ },
291
+ onRollup(listener): () => void {
292
+ listeners.add(listener);
293
+ return () => {
294
+ listeners.delete(listener);
295
+ };
296
+ },
297
+ };
298
+ }
299
+
300
+ /** W-092: default retention caps - generous for a long-lived assistant. */
301
+ const DEFAULT_MAX_SPAN_ENTRIES = 10_000;
302
+ const DEFAULT_MAX_SCOPE_ENTRIES = 10_000;
303
+
304
+ function pickBudget(budgets: CostBudgets, scope: CostScope): number | undefined {
305
+ switch (scope) {
306
+ case 'session':
307
+ return budgets.perSession;
308
+ case 'user':
309
+ return budgets.perUser;
310
+ case 'agent':
311
+ return budgets.perAgent;
312
+ case 'run':
313
+ return budgets.perRun;
314
+ }
315
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * W-092: the documented-but-missing bridge between the provider
3
+ * middleware `withCostTracking` and a {@link CostTracker}. The
4
+ * middleware's `onUsage` hook carries per-call figures (incl. the
5
+ * prompt-cache legs and a computed `costUsd`); this adapter converts
6
+ * them into a {@link CostRecordInput} and records it - STRUCTURALLY,
7
+ * with no `@graphorin/provider` import, so the observability package
8
+ * keeps its dependency profile.
9
+ *
10
+ * ```ts
11
+ * const tracker = createCostTracker({ budgets: { perSession: 5 } });
12
+ * const provider = withCostTracking(base, {
13
+ * priceLookup,
14
+ * onUsage: costTrackerUsageDelegate(tracker, () => ({
15
+ * spanId: currentSpanId(),
16
+ * sessionId: currentSessionId(),
17
+ * })),
18
+ * });
19
+ * ```
20
+ *
21
+ * @packageDocumentation
22
+ */
23
+
24
+ import type { CostTracker } from './cost-tracker.js';
25
+ import type { CostRecordInput } from './types.js';
26
+
27
+ /**
28
+ * Structural mirror of the info object `withCostTracking`'s `onUsage`
29
+ * hook receives (no provider dependency).
30
+ *
31
+ * @stable
32
+ */
33
+ export interface ProviderUsageInfoLike {
34
+ readonly modelId: string;
35
+ readonly promptTokens: number;
36
+ readonly completionTokens: number;
37
+ readonly cachedReadTokens?: number;
38
+ readonly cacheWriteTokens?: number;
39
+ readonly costUsd: number;
40
+ }
41
+
42
+ /**
43
+ * Attribution ids for one recorded call. `spanId` is mandatory (the
44
+ * tracker keys rollups on it); everything else is optional scope
45
+ * attribution.
46
+ *
47
+ * @stable
48
+ */
49
+ export type CostTrackerDelegateIds = Pick<
50
+ CostRecordInput,
51
+ 'spanId' | 'parentSpanId' | 'runId' | 'sessionId' | 'agentId' | 'userId'
52
+ >;
53
+
54
+ /**
55
+ * Build an `onUsage` callback that records into `tracker`. Pass either
56
+ * static ids (a provider instance bound to one session) or a resolver
57
+ * invoked per call (a shared provider serving many runs). A zero
58
+ * `costUsd` records token figures WITHOUT a cost so a price-less
59
+ * middleware does not fabricate a $0 USD cost entry.
60
+ *
61
+ * @stable
62
+ */
63
+ export function costTrackerUsageDelegate(
64
+ tracker: Pick<CostTracker, 'record'>,
65
+ ids: CostTrackerDelegateIds | ((info: ProviderUsageInfoLike) => CostTrackerDelegateIds),
66
+ ): (info: ProviderUsageInfoLike) => void {
67
+ return (info) => {
68
+ const resolved = typeof ids === 'function' ? ids(info) : ids;
69
+ tracker.record({
70
+ model: info.modelId,
71
+ promptTokens: info.promptTokens,
72
+ completionTokens: info.completionTokens,
73
+ ...(info.cachedReadTokens !== undefined ? { cachedReadTokens: info.cachedReadTokens } : {}),
74
+ ...(info.cacheWriteTokens !== undefined ? { cacheWriteTokens: info.cacheWriteTokens } : {}),
75
+ ...(info.costUsd > 0 ? { cost: { amount: info.costUsd, currency: 'USD' } } : {}),
76
+ ...resolved,
77
+ });
78
+ };
79
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Cost / token tracker surface.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export {
8
+ type CostTracker,
9
+ createCostTracker,
10
+ } from './cost-tracker.js';
11
+ export {
12
+ type CostTrackerDelegateIds,
13
+ costTrackerUsageDelegate,
14
+ type ProviderUsageInfoLike,
15
+ } from './delegate.js';
16
+ export type {
17
+ CostBudgetExceededCallback,
18
+ CostBudgets,
19
+ CostRecordInput,
20
+ CostScope,
21
+ CostSnapshot,
22
+ CostTrackerOptions,
23
+ } from './types.js';
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Public types for the cost-tracker surface.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import type { Cost } from '@graphorin/core';
8
+
9
+ /**
10
+ * Aggregation scope used by {@link CostTracker.usage}. The framework
11
+ * tracks four canonical scopes; deployments needing additional
12
+ * dimensions can build them by registering listeners on
13
+ * `CostTracker.onRollup(...)`.
14
+ *
15
+ * @stable
16
+ */
17
+ export type CostScope = 'run' | 'session' | 'agent' | 'user';
18
+
19
+ /**
20
+ * Per-call usage record fed into {@link CostTracker.record}.
21
+ *
22
+ * @stable
23
+ */
24
+ export interface CostRecordInput {
25
+ readonly model: string;
26
+ readonly promptTokens: number;
27
+ readonly completionTokens: number;
28
+ readonly reasoningTokens?: number;
29
+ /** Prompt-cache READ leg (W-092; name mirrors core `Usage`). */
30
+ readonly cachedReadTokens?: number;
31
+ /** Prompt-cache WRITE leg (W-092; name mirrors core `Usage`). */
32
+ readonly cacheWriteTokens?: number;
33
+ readonly cost?: Cost;
34
+ /** Span id; used to thread parent-child rollups. */
35
+ readonly spanId: string;
36
+ /** Optional parent span id (when the call is part of a nested run). */
37
+ readonly parentSpanId?: string;
38
+ readonly runId?: string;
39
+ readonly sessionId?: string;
40
+ readonly agentId?: string;
41
+ readonly userId?: string;
42
+ }
43
+
44
+ /**
45
+ * Snapshot returned by {@link CostTracker.usage}.
46
+ *
47
+ * @stable
48
+ */
49
+ export interface CostSnapshot {
50
+ readonly totalTokens: number;
51
+ readonly promptTokens: number;
52
+ readonly completionTokens: number;
53
+ readonly reasoningTokens: number;
54
+ /** W-092: aggregated prompt-cache READ tokens (0 when never recorded). */
55
+ readonly cachedReadTokens: number;
56
+ /** W-092: aggregated prompt-cache WRITE tokens (0 when never recorded). */
57
+ readonly cacheWriteTokens: number;
58
+ readonly callCount: number;
59
+ /**
60
+ * Aggregated cost in WHOLE currency units (W-045; for USD - dollars,
61
+ * fractional values expected). Same convention as core `Cost.amount`
62
+ * and `@graphorin/pricing.calculateCost` - never minor units.
63
+ */
64
+ readonly cost: Cost | null;
65
+ /**
66
+ * RP-22: `true` when records carrying differing currencies were aggregated
67
+ * into this snapshot. `cost.amount` is then a sum across currencies and must
68
+ * not be treated as a single clean figure.
69
+ */
70
+ readonly mixedCurrency: boolean;
71
+ readonly byModel: ReadonlyArray<{
72
+ readonly model: string;
73
+ readonly promptTokens: number;
74
+ readonly completionTokens: number;
75
+ readonly reasoningTokens: number;
76
+ /** W-092: per-model prompt-cache READ tokens. */
77
+ readonly cachedReadTokens: number;
78
+ /** W-092: per-model prompt-cache WRITE tokens. */
79
+ readonly cacheWriteTokens: number;
80
+ readonly callCount: number;
81
+ readonly cost: Cost | null;
82
+ readonly mixedCurrency: boolean;
83
+ }>;
84
+ }
85
+
86
+ /**
87
+ * Budget configuration shape consumed by {@link createCostTracker}.
88
+ *
89
+ * @stable
90
+ */
91
+ export interface CostBudgets {
92
+ /** Per-session budget in the configured currency. */
93
+ readonly perSession?: number;
94
+ /** Per-user budget. */
95
+ readonly perUser?: number;
96
+ /** Per-agent budget. */
97
+ readonly perAgent?: number;
98
+ /** Per-run budget. */
99
+ readonly perRun?: number;
100
+ /** Currency. Defaults to `'USD'`. */
101
+ readonly currency?: string;
102
+ }
103
+
104
+ /**
105
+ * Callback invoked when an aggregation scope crosses its configured
106
+ * budget. The handler receives a sanitized payload - the secret-free
107
+ * scope id + the breached numbers.
108
+ *
109
+ * @stable
110
+ */
111
+ export type CostBudgetExceededCallback = (event: {
112
+ readonly scope: CostScope;
113
+ readonly id: string;
114
+ readonly budget: number;
115
+ readonly actual: number;
116
+ readonly currency: string;
117
+ }) => void;
118
+
119
+ /**
120
+ * Configuration shape for {@link createCostTracker}.
121
+ *
122
+ * @stable
123
+ */
124
+ export interface CostTrackerOptions {
125
+ readonly budgets?: CostBudgets;
126
+ readonly onExceed?: CostBudgetExceededCallback;
127
+ /**
128
+ * W-092: memory bound for the tracker's internal maps. The tracker
129
+ * aggregates for the LIFETIME of the process - exactly the
130
+ * long-running-assistant scenario - so unbounded per-span / per-scope
131
+ * maps are a leak. When an insertion pushes a map past its limit the
132
+ * OLDEST entries (insertion order, not LRU) are evicted and
133
+ * `onEviction` fires per dropped id; `usage()` / `usageForSpan()` for
134
+ * an evicted id then report zero figures, and a late rollup to an
135
+ * evicted ancestor re-creates it from zero. Defaults to
136
+ * `{ maxSpanEntries: 10_000, maxScopeEntries: 10_000 }`; pass `false`
137
+ * to restore the previous unbounded behaviour.
138
+ */
139
+ readonly retention?:
140
+ | {
141
+ readonly maxSpanEntries?: number;
142
+ /** Per scope-kind cap (each of run/session/agent/user maps). */
143
+ readonly maxScopeEntries?: number;
144
+ }
145
+ | false;
146
+ /** W-092: observer for retention evictions (dashboards / warnings). */
147
+ readonly onEviction?: (event: {
148
+ readonly surface: 'span' | CostScope;
149
+ readonly id: string;
150
+ }) => void;
151
+ }