@viveka10/cap-ai-token-optimizer 0.0.0-stage → 0.1.1
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/CHANGELOG.md +83 -0
- package/LICENSE +21 -0
- package/README.md +753 -2
- package/SECURITY.md +39 -0
- package/cds-plugin.js +50 -0
- package/db/token-usage-log.cds +44 -0
- package/docs/existing-cap-app-on-btp.md +425 -0
- package/docs/integration-guide.md +140 -0
- package/index.d.ts +291 -0
- package/index.js +36 -0
- package/lib/cache/index.js +67 -0
- package/lib/client/adapters.js +263 -0
- package/lib/client/instrument.js +124 -0
- package/lib/config.js +128 -0
- package/lib/exporters/console.js +18 -0
- package/lib/exporters/db.js +169 -0
- package/lib/exporters/http.js +143 -0
- package/lib/exporters/index.js +42 -0
- package/lib/exporters/otel.js +126 -0
- package/lib/metrics/classifier.js +175 -0
- package/lib/metrics/collector.js +206 -0
- package/lib/metrics/events.js +124 -0
- package/lib/optimizer.js +467 -0
- package/lib/pipeline/index.js +90 -0
- package/lib/pipeline/messages.js +62 -0
- package/lib/pipeline/steps/dedupe.js +103 -0
- package/lib/pipeline/steps/history.js +63 -0
- package/lib/pipeline/steps/normalize.js +88 -0
- package/lib/pipeline/steps/outputCap.js +28 -0
- package/lib/pipeline/steps/tools.js +157 -0
- package/lib/pipeline/text.js +166 -0
- package/lib/tokens/estimator.js +163 -0
- package/lib/util/cds.js +13 -0
- package/lib/util/hash.js +40 -0
- package/lib/util/log.js +37 -0
- package/lib/util/peer.js +55 -0
- package/package.json +141 -4
- package/srv/admin-service.cds +83 -0
- package/srv/admin-service.js +41 -0
- package/srv/admin-usage-log.cds +8 -0
|
@@ -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 }
|