@viveka10/cap-ai-token-optimizer 0.0.0-stage → 0.1.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.
@@ -0,0 +1,206 @@
1
+ 'use strict'
2
+ const { EventEmitter } = require('node:events')
3
+ const log = require('../util/log')
4
+
5
+ const LATENCY_SAMPLES = 1000
6
+
7
+ function emptyTotals () {
8
+ return {
9
+ calls: 0,
10
+ success: 0,
11
+ errors: 0,
12
+ cacheHits: 0,
13
+ tokens: { prompt: 0, completion: 0, total: 0, cached: 0 },
14
+ savedTokensEstimate: 0,
15
+ costEstimate: 0
16
+ }
17
+ }
18
+
19
+ /**
20
+ * Receives call events, keeps in-memory aggregates, notifies listeners and fans out to exporters.
21
+ * Listener and exporter failures are caught and counted — they never reach the AI call.
22
+ */
23
+ class MetricsCollector extends EventEmitter {
24
+ constructor () {
25
+ super()
26
+ /** @type {Array<import('../../index').Exporter>} */
27
+ this.exporters = []
28
+ this._timer = null
29
+ this.reset()
30
+ }
31
+
32
+ reset () {
33
+ this._since = new Date().toISOString()
34
+ this._totals = emptyTotals()
35
+ this._errorsByCategory = {}
36
+ this._savingsByStep = {}
37
+ this._byUseCase = {}
38
+ this._byModel = {}
39
+ this._latencies = []
40
+ this._prefixChanges = {}
41
+ this._listenerErrors = 0
42
+ this._exporterErrors = {}
43
+ }
44
+
45
+ /** @param {import('../../index').Exporter} exporter */
46
+ addExporter (exporter) {
47
+ if (!exporter || typeof exporter.export !== 'function') throw new TypeError('Exporter must implement export(event)')
48
+ this.exporters.push(exporter)
49
+ }
50
+
51
+ /**
52
+ * Record one call event. Never throws.
53
+ * @param {object} event
54
+ */
55
+ record (event) {
56
+ try {
57
+ this._aggregate(event)
58
+ } catch (e) {
59
+ log.warn('metrics aggregation failed:', e?.message)
60
+ }
61
+ this._safeEmit('call', event)
62
+ for (const exporter of this.exporters) {
63
+ try {
64
+ const r = exporter.export(event)
65
+ if (r && typeof r.catch === 'function') r.catch(err => this._exporterFailed(exporter, err))
66
+ } catch (err) {
67
+ this._exporterFailed(exporter, err)
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Emit a non-call event (e.g. 'warning'). Never throws. */
73
+ notify (type, payload) {
74
+ this._safeEmit(type, payload)
75
+ }
76
+
77
+ _safeEmit (type, payload) {
78
+ for (const listener of this.rawListeners(type)) {
79
+ try {
80
+ const r = listener.call(this, payload)
81
+ if (r && typeof r.catch === 'function') r.catch(e => { this._listenerErrors++; log.warn(`'${type}' listener failed:`, e?.message) })
82
+ } catch (e) {
83
+ this._listenerErrors++
84
+ log.warn(`'${type}' listener failed:`, e?.message)
85
+ }
86
+ }
87
+ }
88
+
89
+ _exporterFailed (exporter, err) {
90
+ const name = exporter.name ?? 'anonymous'
91
+ this._exporterErrors[name] = (this._exporterErrors[name] ?? 0) + 1
92
+ log.warn(`exporter '${name}' failed:`, err?.message)
93
+ }
94
+
95
+ _aggregate (e) {
96
+ const buckets = [this._totals, bucket(this._byUseCase, e.useCase ?? 'default'), bucket(this._byModel, e.model ?? 'unknown')]
97
+ for (const b of buckets) {
98
+ b.calls++
99
+ if (e.status === 'error') b.errors++
100
+ else b.success++
101
+ if (e.savings?.cacheHit) b.cacheHits++
102
+ b.tokens.prompt += e.tokens?.prompt ?? 0
103
+ b.tokens.completion += e.tokens?.completion ?? 0
104
+ b.tokens.total += e.tokens?.total ?? 0
105
+ b.tokens.cached += e.tokens?.cached ?? 0
106
+ b.savedTokensEstimate += e.savings?.estimatedTokens ?? 0
107
+ b.costEstimate = round6(b.costEstimate + (e.costEstimate ?? 0))
108
+ }
109
+ if (e.status === 'error') {
110
+ const cat = e.error?.category ?? 'UNKNOWN'
111
+ this._errorsByCategory[cat] = (this._errorsByCategory[cat] ?? 0) + 1
112
+ }
113
+ for (const [step, saved] of Object.entries(e.savings?.byStep ?? {})) {
114
+ this._savingsByStep[step] = (this._savingsByStep[step] ?? 0) + (Number(saved) || 0)
115
+ }
116
+ if (e.advisory?.promptPrefixChanged) {
117
+ const uc = e.useCase ?? 'default'
118
+ this._prefixChanges[uc] = (this._prefixChanges[uc] ?? 0) + 1
119
+ }
120
+ if (Number.isFinite(e.latencyMs)) {
121
+ this._latencies.push(e.latencyMs)
122
+ if (this._latencies.length > LATENCY_SAMPLES) this._latencies.shift()
123
+ }
124
+ }
125
+
126
+ /** Aggregated stats since start or last reset(). Returns a fresh copy. */
127
+ stats () {
128
+ return structuredClone({
129
+ since: this._since,
130
+ ...this._totals,
131
+ errorsByCategory: this._errorsByCategory,
132
+ savingsByStep: this._savingsByStep,
133
+ latencyMs: latencyStats(this._latencies),
134
+ byUseCase: this._byUseCase,
135
+ byModel: this._byModel,
136
+ // prompt-caching advisory: calls whose system prompt or tools changed, per use case
137
+ promptPrefixChanges: this._prefixChanges,
138
+ listenerErrors: this._listenerErrors,
139
+ exporterErrors: this._exporterErrors,
140
+ exporters: this._exporterStats()
141
+ })
142
+ }
143
+
144
+ /** Stats reported by exporters that have a stats() method (e.g. http: queued, sent, dropped). */
145
+ _exporterStats () {
146
+ const out = {}
147
+ for (const x of this.exporters) {
148
+ if (typeof x.stats !== 'function') continue
149
+ try { out[x.name ?? 'anonymous'] = x.stats() } catch { /* stats are best effort */ }
150
+ }
151
+ return out
152
+ }
153
+
154
+ /**
155
+ * Start periodic flushing of buffering exporters.
156
+ * @param {number} intervalMs
157
+ */
158
+ start (intervalMs) {
159
+ if (this._timer || !(intervalMs > 0)) return
160
+ this._timer = setInterval(() => { this.flush() }, intervalMs)
161
+ this._timer.unref?.()
162
+ }
163
+
164
+ /** Stop periodic flushing. */
165
+ stop () {
166
+ if (this._timer) { clearInterval(this._timer); this._timer = null }
167
+ }
168
+
169
+ /** Flush all exporters. Never rejects. */
170
+ async flush () {
171
+ await Promise.all(this.exporters.map(async (x) => {
172
+ try { await x.flush?.() } catch (err) { this._exporterFailed(x, err) }
173
+ }))
174
+ }
175
+
176
+ /** Stop the timer, flush and shut down all exporters. Never rejects. */
177
+ async shutdown () {
178
+ this.stop()
179
+ await this.flush()
180
+ await Promise.all(this.exporters.map(async (x) => {
181
+ try { await x.shutdown?.() } catch (err) { this._exporterFailed(x, err) }
182
+ }))
183
+ }
184
+ }
185
+
186
+ function bucket (map, key) {
187
+ map[key] ??= emptyTotals()
188
+ return map[key]
189
+ }
190
+
191
+ function latencyStats (samples) {
192
+ if (!samples.length) return { count: 0, avg: null, p50: null, p95: null, max: null }
193
+ const sorted = [...samples].sort((a, b) => a - b)
194
+ const pct = (p) => sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)]
195
+ return {
196
+ count: sorted.length,
197
+ avg: Math.round(sorted.reduce((a, b) => a + b, 0) / sorted.length),
198
+ p50: pct(50),
199
+ p95: pct(95),
200
+ max: sorted[sorted.length - 1]
201
+ }
202
+ }
203
+
204
+ const round6 = (n) => Math.round(n * 1e6) / 1e6
205
+
206
+ module.exports = { MetricsCollector }
@@ -0,0 +1,124 @@
1
+ 'use strict'
2
+ const { randomUUID } = require('node:crypto')
3
+ const { hashUser } = require('../util/hash')
4
+ const { getCds } = require('../util/cds')
5
+
6
+ const SCHEMA_VERSION = 1
7
+
8
+ /**
9
+ * Read tenant / user / service from cds.context, if CAP is loaded.
10
+ * `cdsService` falls back to the HTTP base path (e.g. /odata/v4/agent); pass `cdsService`
11
+ * explicitly in wrap()/chat() options for the CDS service name.
12
+ * @returns {{ tenant: string|null, userId: string|null, cdsService: string|null }}
13
+ */
14
+ function cdsContextInfo () {
15
+ try {
16
+ const ctx = getCds()?.context
17
+ if (!ctx) return { tenant: null, userId: null, cdsService: null }
18
+ const user = ctx.user
19
+ const userId = user && !user._is_anonymous && user.id !== 'anonymous' ? user.id : null
20
+ return {
21
+ tenant: ctx.tenant ?? null,
22
+ userId: userId ?? null,
23
+ cdsService: ctx.http?.req?.baseUrl || null
24
+ }
25
+ } catch {
26
+ return { tenant: null, userId: null, cdsService: null }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Cost estimate from per-1k prices. Cached prompt tokens use `cachedInputPer1k` when configured.
32
+ * @param {string|null} model
33
+ * @param {{ prompt: number|null, completion: number|null, cached: number|null }} tokens
34
+ * @param {Record<string, { inputPer1k?: number, outputPer1k?: number, cachedInputPer1k?: number }>} pricing
35
+ * @returns {number|null}
36
+ */
37
+ function estimateCost (model, tokens, pricing) {
38
+ const price = model ? (pricing?.[model] ?? pricing?.[String(model).replace(/^.*(--|\/)/, '')]) : null
39
+ if (!price || tokens.prompt == null) return null
40
+ const cached = Math.min(tokens.cached ?? 0, tokens.prompt)
41
+ const cachedRate = price.cachedInputPer1k ?? price.inputPer1k ?? 0
42
+ const cost = ((tokens.prompt - cached) * (price.inputPer1k ?? 0) +
43
+ cached * cachedRate +
44
+ (tokens.completion ?? 0) * (price.outputPer1k ?? 0)) / 1000
45
+ return Math.round(cost * 1e6) / 1e6
46
+ }
47
+
48
+ /**
49
+ * Normalise SDK token usage into the event's token fields.
50
+ * Works for orchestration and Azure OpenAI responses (same OpenAI-style usage shape).
51
+ * @param {any} usage result of response.getTokenUsage()
52
+ */
53
+ function usageToTokens (usage) {
54
+ if (!usage || typeof usage !== 'object') return { prompt: null, completion: null, total: null, cached: null }
55
+ const num = (v) => (Number.isFinite(v) ? v : null)
56
+ const prompt = num(usage.prompt_tokens)
57
+ const completion = num(usage.completion_tokens)
58
+ return {
59
+ prompt,
60
+ completion,
61
+ total: num(usage.total_tokens) ?? (prompt != null && completion != null ? prompt + completion : null),
62
+ cached: num(usage.prompt_tokens_details?.cached_tokens) ?? 0
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Build a complete metrics event. Missing fields get explicit null defaults so the
68
+ * shape is stable for exporters.
69
+ * @param {object} p
70
+ * @param {object} [opts]
71
+ * @param {string} [opts.userHashSalt]
72
+ */
73
+ function buildEvent (p, { userHashSalt } = {}) {
74
+ const tokens = {
75
+ promptEstimatedBefore: p.tokens?.promptEstimatedBefore ?? null,
76
+ promptEstimatedAfter: p.tokens?.promptEstimatedAfter ?? null,
77
+ prompt: p.tokens?.prompt ?? null,
78
+ completion: p.tokens?.completion ?? null,
79
+ total: p.tokens?.total ?? null,
80
+ cached: p.tokens?.cached ?? null
81
+ }
82
+ const event = {
83
+ schemaVersion: SCHEMA_VERSION,
84
+ id: p.id ?? randomUUID(),
85
+ timestamp: p.timestamp ?? new Date().toISOString(),
86
+ tenant: p.tenant ?? null,
87
+ user: hashUser(p.userId, userHashSalt),
88
+ cdsService: p.cdsService ?? null,
89
+ useCase: p.useCase ?? 'default',
90
+ mode: p.mode ?? 'active',
91
+ model: p.model ?? null,
92
+ deploymentId: p.deploymentId ?? null,
93
+ resourceGroup: p.resourceGroup ?? null,
94
+ sdk: p.sdk ?? null,
95
+ operation: p.operation ?? 'chat',
96
+ requestId: p.requestId ?? null,
97
+ finishReason: p.finishReason ?? null,
98
+ estimateMethod: p.estimateMethod ?? null,
99
+ tokens,
100
+ savings: {
101
+ estimatedTokens: p.savings?.estimatedTokens ?? 0,
102
+ byStep: p.savings?.byStep ?? {},
103
+ cacheHit: p.savings?.cacheHit ?? false,
104
+ // short step notes (counts only, never content), and whether the pipeline failed
105
+ notes: p.savings?.notes ?? [],
106
+ failed: p.savings?.failed ?? false
107
+ },
108
+ costEstimate: p.costEstimate ?? null,
109
+ latencyMs: p.latencyMs ?? null,
110
+ retries: p.retries ?? 0,
111
+ status: p.status ?? 'success',
112
+ error: p.error
113
+ ? { category: p.error.category, httpStatus: p.error.httpStatus ?? null, code: p.error.code ?? null, message: p.error.message ?? '' }
114
+ : null,
115
+ promptHash: p.promptHash ?? null,
116
+ promptLength: p.promptLength ?? null,
117
+ // prompt-caching advisory; null when not checked (disabled, cache hit)
118
+ advisory: p.advisory ?? null
119
+ }
120
+ if (p.content) event.content = p.content
121
+ return event
122
+ }
123
+
124
+ module.exports = { buildEvent, estimateCost, usageToTokens, cdsContextInfo, SCHEMA_VERSION }