@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,169 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Database exporter (`db`, alias `hana`): writes one TokenUsageLog row per call through
|
|
4
|
+
* CAP's database service, so it works with SAP HANA, SQLite and PostgreSQL.
|
|
5
|
+
*
|
|
6
|
+
* - Rows are buffered and inserted in batches on the flush interval, when a batch is full,
|
|
7
|
+
* and on shutdown. Each tenant's rows go to that tenant's container (cds.tx with the tenant).
|
|
8
|
+
* - The queue is capped; when full, the oldest rows are dropped and counted. Failed inserts
|
|
9
|
+
* are kept for the next flush (up to the cap).
|
|
10
|
+
* - Rows older than `retentionDays` are deleted at most once a day per tenant (0 keeps all).
|
|
11
|
+
* - Only metrics are stored, never prompt or completion text (even with captureContent).
|
|
12
|
+
*
|
|
13
|
+
* Never throws into the caller.
|
|
14
|
+
*/
|
|
15
|
+
const log = require('../util/log')
|
|
16
|
+
const { getCds } = require('../util/cds')
|
|
17
|
+
|
|
18
|
+
const ENTITY = 'cap.ai.token_optimizer.TokenUsageLog'
|
|
19
|
+
const DAY = 24 * 60 * 60 * 1000
|
|
20
|
+
const DEFAULTS = { batchSize: 50, maxQueueSize: 10000, retentionDays: 30 }
|
|
21
|
+
|
|
22
|
+
const int = (v) => (Number.isFinite(v) ? Math.round(v) : null)
|
|
23
|
+
const str = (v, max) => (v == null ? null : String(v).slice(0, max))
|
|
24
|
+
|
|
25
|
+
/** Map a call event to a TokenUsageLog row. */
|
|
26
|
+
function toRow (e) {
|
|
27
|
+
return {
|
|
28
|
+
ID: e.id,
|
|
29
|
+
timestamp: e.timestamp,
|
|
30
|
+
tenant: str(e.tenant, 255),
|
|
31
|
+
userHash: e.user ?? null,
|
|
32
|
+
cdsService: str(e.cdsService, 255),
|
|
33
|
+
useCase: str(e.useCase, 255),
|
|
34
|
+
mode: e.mode,
|
|
35
|
+
model: str(e.model, 255),
|
|
36
|
+
deploymentId: str(e.deploymentId, 255),
|
|
37
|
+
resourceGroup: str(e.resourceGroup, 255),
|
|
38
|
+
sdk: e.sdk ?? null,
|
|
39
|
+
operation: e.operation ?? null,
|
|
40
|
+
requestId: str(e.requestId, 255),
|
|
41
|
+
finishReason: str(e.finishReason, 50),
|
|
42
|
+
promptEstimatedBefore: int(e.tokens?.promptEstimatedBefore),
|
|
43
|
+
promptEstimatedAfter: int(e.tokens?.promptEstimatedAfter),
|
|
44
|
+
promptTokens: int(e.tokens?.prompt),
|
|
45
|
+
completionTokens: int(e.tokens?.completion),
|
|
46
|
+
totalTokens: int(e.tokens?.total),
|
|
47
|
+
cachedTokens: int(e.tokens?.cached),
|
|
48
|
+
savedTokensEstimate: int(e.savings?.estimatedTokens),
|
|
49
|
+
savingsByStep: JSON.stringify(e.savings?.byStep ?? {}),
|
|
50
|
+
cacheHit: !!e.savings?.cacheHit,
|
|
51
|
+
promptPrefixChanged: !!e.advisory?.promptPrefixChanged,
|
|
52
|
+
costEstimate: e.costEstimate ?? null,
|
|
53
|
+
latencyMs: int(e.latencyMs),
|
|
54
|
+
retries: int(e.retries) ?? 0,
|
|
55
|
+
status: e.status,
|
|
56
|
+
errorCategory: e.error?.category ?? null,
|
|
57
|
+
errorHttpStatus: int(e.error?.httpStatus),
|
|
58
|
+
errorCode: str(e.error?.code, 100),
|
|
59
|
+
errorMessage: str(e.error?.message, 300),
|
|
60
|
+
promptHash: e.promptHash ?? null,
|
|
61
|
+
promptLength: int(e.promptLength)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {object} [cfg] metrics.db config
|
|
67
|
+
* @param {{ cds?: any, now?: () => number }} [deps]
|
|
68
|
+
* @returns {import('../../index').Exporter}
|
|
69
|
+
*/
|
|
70
|
+
function createDbExporter (cfg = {}, deps = {}) {
|
|
71
|
+
const opts = { ...DEFAULTS, ...cfg }
|
|
72
|
+
const now = deps.now ?? Date.now
|
|
73
|
+
const queue = []
|
|
74
|
+
const counters = { written: 0, dropped: 0, failedWrites: 0, deleted: 0 }
|
|
75
|
+
const lastCleanup = new Map()
|
|
76
|
+
let flushing = null
|
|
77
|
+
let db = null
|
|
78
|
+
let unavailable = false
|
|
79
|
+
|
|
80
|
+
const cds = () => deps.cds ?? getCds()
|
|
81
|
+
|
|
82
|
+
async function connect () {
|
|
83
|
+
if (db) return db
|
|
84
|
+
const c = cds()
|
|
85
|
+
if (!c) throw new Error('@sap/cds is not loaded')
|
|
86
|
+
db = await c.connect.to('db')
|
|
87
|
+
if (!db.model?.definitions?.[ENTITY] && !c.model?.definitions?.[ENTITY]) {
|
|
88
|
+
throw new Error(`entity ${ENTITY} is not in the model (is the @viveka10/cap-ai-token-optimizer plugin loaded?)`)
|
|
89
|
+
}
|
|
90
|
+
return db
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function enqueue (rows, front = false) {
|
|
94
|
+
if (front) queue.unshift(...rows)
|
|
95
|
+
else queue.push(...rows)
|
|
96
|
+
const over = queue.length - opts.maxQueueSize
|
|
97
|
+
if (over > 0) {
|
|
98
|
+
queue.splice(0, over)
|
|
99
|
+
counters.dropped += over
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writeTenant (service, tenant, rows) {
|
|
104
|
+
const c = cds()
|
|
105
|
+
const ctx = { tenant: tenant ?? undefined, user: new c.User.Privileged() }
|
|
106
|
+
await service.tx(ctx, async (tx) => {
|
|
107
|
+
await tx.run(c.ql.INSERT.into(ENTITY).entries(rows))
|
|
108
|
+
const key = tenant ?? ''
|
|
109
|
+
if (opts.retentionDays > 0 && !(now() - (lastCleanup.get(key) ?? 0) < DAY)) {
|
|
110
|
+
lastCleanup.set(key, now())
|
|
111
|
+
const cutoff = new Date(now() - opts.retentionDays * DAY).toISOString()
|
|
112
|
+
const n = await tx.run(c.ql.DELETE.from(ENTITY).where({ timestamp: { '<': cutoff } }))
|
|
113
|
+
counters.deleted += Number(n) || 0
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function drain () {
|
|
119
|
+
if (!queue.length || unavailable) return
|
|
120
|
+
let service
|
|
121
|
+
try {
|
|
122
|
+
service = await connect()
|
|
123
|
+
} catch (e) {
|
|
124
|
+
unavailable = true
|
|
125
|
+
counters.dropped += queue.length
|
|
126
|
+
queue.length = 0
|
|
127
|
+
log.warn('db exporter disabled:', e?.message)
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const rows = queue.splice(0, queue.length)
|
|
131
|
+
const byTenant = new Map()
|
|
132
|
+
for (const r of rows) {
|
|
133
|
+
if (!byTenant.has(r.tenant)) byTenant.set(r.tenant, [])
|
|
134
|
+
byTenant.get(r.tenant).push(r)
|
|
135
|
+
}
|
|
136
|
+
for (const [tenant, tenantRows] of byTenant) {
|
|
137
|
+
try {
|
|
138
|
+
await writeTenant(service, tenant, tenantRows)
|
|
139
|
+
counters.written += tenantRows.length
|
|
140
|
+
} catch (e) {
|
|
141
|
+
counters.failedWrites++
|
|
142
|
+
enqueue(tenantRows, true)
|
|
143
|
+
log.warn(`db exporter: could not write ${tenantRows.length} row(s):`, e?.message)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function flush () {
|
|
149
|
+
flushing ??= drain().catch(e => log.warn('db exporter flush failed:', e?.message)).finally(() => { flushing = null })
|
|
150
|
+
return flushing
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
name: 'db',
|
|
155
|
+
export (event) {
|
|
156
|
+
if (unavailable) { counters.dropped++; return }
|
|
157
|
+
enqueue([toRow(event)])
|
|
158
|
+
if (queue.length >= opts.batchSize) flush()
|
|
159
|
+
},
|
|
160
|
+
flush,
|
|
161
|
+
async shutdown () {
|
|
162
|
+
await flushing
|
|
163
|
+
await flush()
|
|
164
|
+
},
|
|
165
|
+
stats: () => ({ queued: queue.length, ...counters })
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { createDbExporter, toRow, ENTITY, DEFAULTS }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* HTTP exporter: POSTs batches of events as JSON `{ "events": [...] }` to a URL or a BTP destination.
|
|
4
|
+
*
|
|
5
|
+
* - Events are queued; the queue is capped (`maxQueueSize`). When full, the oldest events are
|
|
6
|
+
* dropped and counted.
|
|
7
|
+
* - Flushes on the collector's interval, when a batch is full, and on shutdown.
|
|
8
|
+
* - Retries 429, 5xx and network errors with exponential backoff (honours Retry-After);
|
|
9
|
+
* other 4xx responses drop the batch. A batch that still fails goes back to the front of the
|
|
10
|
+
* queue, so a short outage loses nothing (up to the queue cap).
|
|
11
|
+
* - Destinations are resolved with @sap-cloud-sdk/connectivity (present with @sap-ai-sdk) and
|
|
12
|
+
* cached for `destinationCacheMs`. Only Internet destinations are supported.
|
|
13
|
+
*
|
|
14
|
+
* Never throws into the caller.
|
|
15
|
+
*/
|
|
16
|
+
const log = require('../util/log')
|
|
17
|
+
const { loadPeer } = require('../util/peer')
|
|
18
|
+
const { parseRetryAfter } = require('../metrics/classifier')
|
|
19
|
+
|
|
20
|
+
const DEFAULTS = {
|
|
21
|
+
url: '',
|
|
22
|
+
destination: '',
|
|
23
|
+
headers: {},
|
|
24
|
+
batchSize: 100,
|
|
25
|
+
maxQueueSize: 10000,
|
|
26
|
+
maxRetries: 3,
|
|
27
|
+
baseDelayMs: 1000,
|
|
28
|
+
maxDelayMs: 30000,
|
|
29
|
+
timeoutMs: 10000,
|
|
30
|
+
destinationCacheMs: 5 * 60 * 1000
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {object} cfg metrics.http config
|
|
35
|
+
* @param {{ fetch?: typeof fetch, sleep?: (ms: number) => Promise<void>, connectivity?: object, now?: () => number }} [deps]
|
|
36
|
+
* @returns {import('../../index').Exporter}
|
|
37
|
+
*/
|
|
38
|
+
function createHttpExporter (cfg = {}, deps = {}) {
|
|
39
|
+
const opts = { ...DEFAULTS, ...cfg, headers: { ...cfg.headers } }
|
|
40
|
+
if (!opts.url && !opts.destination) throw new Error('metrics.http needs a "url" or a "destination"')
|
|
41
|
+
const doFetch = deps.fetch ?? globalThis.fetch
|
|
42
|
+
const sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)))
|
|
43
|
+
const now = deps.now ?? Date.now
|
|
44
|
+
|
|
45
|
+
const queue = []
|
|
46
|
+
const counters = { sent: 0, dropped: 0, failedAttempts: 0, rejectedBatches: 0 }
|
|
47
|
+
let flushing = null
|
|
48
|
+
let resolved = null // { url, headers, expires }
|
|
49
|
+
let closed = false
|
|
50
|
+
|
|
51
|
+
async function target () {
|
|
52
|
+
if (opts.url) return { url: opts.url, headers: {} }
|
|
53
|
+
if (resolved && resolved.expires > now()) return resolved
|
|
54
|
+
const connectivity = deps.connectivity ?? loadPeer('@sap-cloud-sdk/connectivity', 'the http exporter with a destination')
|
|
55
|
+
const destination = await connectivity.getDestination({ destinationName: opts.destination, useCache: true })
|
|
56
|
+
if (!destination?.url) throw new Error(`destination "${opts.destination}" not found or has no URL`)
|
|
57
|
+
const headers = await connectivity.buildHeadersForDestination(destination)
|
|
58
|
+
resolved = { url: destination.url, headers, expires: now() + opts.destinationCacheMs }
|
|
59
|
+
return resolved
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** POST one batch. Returns true when sent, false when it should be kept for later. */
|
|
63
|
+
async function send (batch) {
|
|
64
|
+
for (let attempt = 0; ; attempt++) {
|
|
65
|
+
let retryAfter = null
|
|
66
|
+
try {
|
|
67
|
+
const t = await target()
|
|
68
|
+
const res = await doFetch(t.url, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'content-type': 'application/json', ...t.headers, ...opts.headers },
|
|
71
|
+
body: JSON.stringify({ events: batch }),
|
|
72
|
+
signal: AbortSignal.timeout(opts.timeoutMs)
|
|
73
|
+
})
|
|
74
|
+
if (res.ok) {
|
|
75
|
+
counters.sent += batch.length
|
|
76
|
+
return true
|
|
77
|
+
}
|
|
78
|
+
if (res.status === 401 || res.status === 403) resolved = null // token may have expired
|
|
79
|
+
if (res.status !== 429 && res.status < 500 && res.status !== 401) {
|
|
80
|
+
counters.rejectedBatches++
|
|
81
|
+
log.warn(`http exporter: batch of ${batch.length} rejected with HTTP ${res.status}, dropped`)
|
|
82
|
+
counters.dropped += batch.length
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
retryAfter = parseRetryAfter(res.headers?.get?.('retry-after'), now())
|
|
86
|
+
counters.failedAttempts++
|
|
87
|
+
log.debug(`http exporter: HTTP ${res.status} (attempt ${attempt + 1})`)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
counters.failedAttempts++
|
|
90
|
+
log.debug(`http exporter: ${e?.message} (attempt ${attempt + 1})`)
|
|
91
|
+
if (attempt >= opts.maxRetries) log.warn('http exporter: could not send events:', e?.message)
|
|
92
|
+
}
|
|
93
|
+
if (attempt >= opts.maxRetries) return false
|
|
94
|
+
const backoff = opts.baseDelayMs * 2 ** attempt
|
|
95
|
+
await sleep(Math.min(opts.maxDelayMs, retryAfter ?? Math.round(backoff / 2 + Math.random() * backoff / 2)))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function enqueue (events, front = false) {
|
|
100
|
+
if (front) queue.unshift(...events)
|
|
101
|
+
else queue.push(...events)
|
|
102
|
+
const over = queue.length - opts.maxQueueSize
|
|
103
|
+
if (over > 0) {
|
|
104
|
+
// drop the oldest
|
|
105
|
+
queue.splice(0, over)
|
|
106
|
+
counters.dropped += over
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function drain () {
|
|
111
|
+
while (queue.length) {
|
|
112
|
+
const batch = queue.splice(0, opts.batchSize)
|
|
113
|
+
const ok = await send(batch)
|
|
114
|
+
if (!ok) {
|
|
115
|
+
enqueue(batch, true)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function flush () {
|
|
122
|
+
flushing ??= drain().catch(e => log.warn('http exporter flush failed:', e?.message)).finally(() => { flushing = null })
|
|
123
|
+
return flushing
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
name: 'http',
|
|
128
|
+
export (event) {
|
|
129
|
+
if (closed) return
|
|
130
|
+
enqueue([event])
|
|
131
|
+
if (queue.length >= opts.batchSize) flush()
|
|
132
|
+
},
|
|
133
|
+
flush,
|
|
134
|
+
async shutdown () {
|
|
135
|
+
await flushing
|
|
136
|
+
await flush()
|
|
137
|
+
closed = true
|
|
138
|
+
},
|
|
139
|
+
stats: () => ({ queued: queue.length, ...counters })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = { createHttpExporter, DEFAULTS }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const log = require('../util/log')
|
|
3
|
+
const { createConsoleExporter } = require('./console')
|
|
4
|
+
const { createHttpExporter } = require('./http')
|
|
5
|
+
const { createOtelExporter } = require('./otel')
|
|
6
|
+
const { createDbExporter } = require('./db')
|
|
7
|
+
|
|
8
|
+
/** Built-in exporter factories. */
|
|
9
|
+
const FACTORIES = {
|
|
10
|
+
console: (cfg, deps) => createConsoleExporter(deps.console),
|
|
11
|
+
http: (cfg, deps) => createHttpExporter(cfg.metrics.http, deps.http),
|
|
12
|
+
otel: (cfg, deps) => createOtelExporter(cfg.metrics.otel, deps.otel),
|
|
13
|
+
db: (cfg, deps) => createDbExporter(cfg.metrics.db, deps.db)
|
|
14
|
+
}
|
|
15
|
+
// `hana` is the name used in the docs for the database exporter
|
|
16
|
+
FACTORIES.hana = FACTORIES.db
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create exporters listed in `cfg.metrics.exporters`. Entries may be names of built-ins
|
|
20
|
+
* or exporter objects. Unknown names are skipped with a warning.
|
|
21
|
+
* @param {object} cfg resolved config
|
|
22
|
+
* @param {object} [deps] injectable dependencies for tests
|
|
23
|
+
*/
|
|
24
|
+
function createExporters (cfg, deps = {}) {
|
|
25
|
+
const out = []
|
|
26
|
+
const entries = [...(cfg.metrics?.exporters ?? [])]
|
|
27
|
+
// `metrics.otel.enabled: true` is a shortcut for listing 'otel'
|
|
28
|
+
if (cfg.metrics?.otel?.enabled && !entries.includes('otel')) entries.push('otel')
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (entry && typeof entry === 'object' && typeof entry.export === 'function') { out.push(entry); continue }
|
|
31
|
+
const factory = FACTORIES[entry]
|
|
32
|
+
if (!factory) { log.warn(`Unknown metrics exporter '${entry}' — skipped`); continue }
|
|
33
|
+
try {
|
|
34
|
+
out.push(factory(cfg, deps))
|
|
35
|
+
} catch (e) {
|
|
36
|
+
log.warn(`Could not create exporter '${entry}':`, e?.message)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { createExporters, FACTORIES }
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* OpenTelemetry exporter (SAP Cloud ALM, Dynatrace, any OTLP backend).
|
|
4
|
+
*
|
|
5
|
+
* Uses only @opentelemetry/api and the globally registered providers. It never creates an
|
|
6
|
+
* SDK or providers itself: install @cap-js/telemetry (or the SAP Cloud ALM agent) to set them
|
|
7
|
+
* up and choose where data goes. Without registered providers, everything is a no-op.
|
|
8
|
+
*
|
|
9
|
+
* Emits per call:
|
|
10
|
+
* - metrics: gen_ai.client.token.usage and gen_ai.client.operation.duration (OpenTelemetry
|
|
11
|
+
* GenAI semantic conventions), plus cap_ai_optimizer.calls / .errors / .tokens.saved /
|
|
12
|
+
* .cache.hits / .cost
|
|
13
|
+
* - one span `chat <model>` (kind CLIENT) with token, savings and error attributes, as a child
|
|
14
|
+
* of the active span (e.g. the CAP request span). Prompt and completion text are never added.
|
|
15
|
+
*
|
|
16
|
+
* Instruments are created on the first event, not at startup: meters taken before
|
|
17
|
+
* @cap-js/telemetry registers its MeterProvider would stay no-ops.
|
|
18
|
+
*/
|
|
19
|
+
const log = require('../util/log')
|
|
20
|
+
const { loadPeer } = require('../util/peer')
|
|
21
|
+
|
|
22
|
+
const { name: SCOPE, version: VERSION } = require('../../package.json')
|
|
23
|
+
const PROVIDER = 'sap.ai_core'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {{ tenantAttribute?: boolean }} [cfg] metrics.otel config
|
|
27
|
+
* @param {{ api?: object }} [deps]
|
|
28
|
+
* @returns {import('../../index').Exporter}
|
|
29
|
+
*/
|
|
30
|
+
function createOtelExporter (cfg = {}, deps = {}) {
|
|
31
|
+
const api = deps.api ?? loadPeer('@opentelemetry/api', 'the otel exporter')
|
|
32
|
+
const tenantAttribute = cfg.tenantAttribute !== false
|
|
33
|
+
let instruments = null
|
|
34
|
+
|
|
35
|
+
function init () {
|
|
36
|
+
const meterProvider = api.metrics.getMeterProvider()
|
|
37
|
+
if (meterProvider?.constructor?.name === 'NoopMeterProvider') {
|
|
38
|
+
log.warn('otel exporter: no OpenTelemetry MeterProvider is registered, metrics are not exported. Install @cap-js/telemetry.')
|
|
39
|
+
}
|
|
40
|
+
const meter = api.metrics.getMeter(SCOPE, VERSION)
|
|
41
|
+
instruments = {
|
|
42
|
+
tracer: api.trace.getTracer(SCOPE, VERSION),
|
|
43
|
+
tokenUsage: meter.createHistogram('gen_ai.client.token.usage', { unit: '{token}', description: 'Tokens used per AI Core call' }),
|
|
44
|
+
duration: meter.createHistogram('gen_ai.client.operation.duration', { unit: 's', description: 'Duration of AI Core calls' }),
|
|
45
|
+
calls: meter.createCounter('cap_ai_optimizer.calls', { unit: '{call}', description: 'AI Core calls' }),
|
|
46
|
+
errors: meter.createCounter('cap_ai_optimizer.errors', { unit: '{call}', description: 'Failed AI Core calls by category' }),
|
|
47
|
+
saved: meter.createCounter('cap_ai_optimizer.tokens.saved', { unit: '{token}', description: 'Estimated tokens saved, by optimization step' }),
|
|
48
|
+
cacheHits: meter.createCounter('cap_ai_optimizer.cache.hits', { unit: '{call}', description: 'Calls answered from the response cache' }),
|
|
49
|
+
cost: meter.createCounter('cap_ai_optimizer.cost', { description: 'Estimated cost, in the currency of the configured prices' }),
|
|
50
|
+
prefixChanges: meter.createCounter('cap_ai_optimizer.prompt_cache.prefix_changes', { unit: '{call}', description: 'Calls whose system prompt or tools changed for the same use case and model (defeats provider prompt caching)' })
|
|
51
|
+
}
|
|
52
|
+
return instruments
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function attributes (e) {
|
|
56
|
+
const a = {
|
|
57
|
+
'gen_ai.operation.name': e.operation ?? 'chat',
|
|
58
|
+
'gen_ai.provider.name': PROVIDER,
|
|
59
|
+
'cap_ai_optimizer.use_case': e.useCase,
|
|
60
|
+
'cap_ai_optimizer.mode': e.mode,
|
|
61
|
+
'cap_ai_optimizer.sdk': e.sdk ?? 'unknown'
|
|
62
|
+
}
|
|
63
|
+
if (e.model) a['gen_ai.request.model'] = e.model
|
|
64
|
+
if (tenantAttribute && e.tenant) a['sap.tenancy.tenant_id'] = e.tenant
|
|
65
|
+
if (e.status === 'error') a['error.type'] = e.error?.category ?? 'UNKNOWN'
|
|
66
|
+
return a
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function recordMetrics (i, e, attrs) {
|
|
70
|
+
if (Number.isFinite(e.latencyMs)) i.duration.record(e.latencyMs / 1000, attrs)
|
|
71
|
+
if (Number.isFinite(e.tokens?.prompt)) i.tokenUsage.record(e.tokens.prompt, { ...attrs, 'gen_ai.token.type': 'input' })
|
|
72
|
+
if (Number.isFinite(e.tokens?.completion)) i.tokenUsage.record(e.tokens.completion, { ...attrs, 'gen_ai.token.type': 'output' })
|
|
73
|
+
i.calls.add(1, attrs)
|
|
74
|
+
if (e.status === 'error') i.errors.add(1, attrs)
|
|
75
|
+
if (e.savings?.cacheHit) i.cacheHits.add(1, attrs)
|
|
76
|
+
for (const [step, saved] of Object.entries(e.savings?.byStep ?? {})) {
|
|
77
|
+
if (saved > 0) i.saved.add(saved, { ...attrs, 'cap_ai_optimizer.step': step })
|
|
78
|
+
}
|
|
79
|
+
if (e.costEstimate > 0) i.cost.add(e.costEstimate, attrs)
|
|
80
|
+
if (e.advisory?.promptPrefixChanged) {
|
|
81
|
+
i.prefixChanges.add(1, { ...attrs, 'cap_ai_optimizer.prefix_part': e.advisory.changed.join(','), 'cap_ai_optimizer.caused_by_optimizer': !!e.advisory.causedByOptimizer })
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function recordSpan (i, e, attrs) {
|
|
86
|
+
const end = Date.parse(e.timestamp)
|
|
87
|
+
const endTime = Number.isFinite(end) ? end : Date.now()
|
|
88
|
+
const startTime = endTime - (Number.isFinite(e.latencyMs) ? e.latencyMs : 0)
|
|
89
|
+
const spanAttrs = { ...attrs }
|
|
90
|
+
const set = (k, v) => { if (v !== null && v !== undefined) spanAttrs[k] = v }
|
|
91
|
+
set('gen_ai.usage.input_tokens', e.tokens?.prompt)
|
|
92
|
+
set('gen_ai.usage.output_tokens', e.tokens?.completion)
|
|
93
|
+
if (e.finishReason) spanAttrs['gen_ai.response.finish_reasons'] = [e.finishReason]
|
|
94
|
+
set('sap.ai_core.request_id', e.requestId)
|
|
95
|
+
set('sap.ai_core.deployment_id', e.deploymentId)
|
|
96
|
+
set('sap.ai_core.resource_group', e.resourceGroup)
|
|
97
|
+
set('cap_ai_optimizer.event_id', e.id)
|
|
98
|
+
set('cap_ai_optimizer.tokens.prompt_estimated_before', e.tokens?.promptEstimatedBefore)
|
|
99
|
+
set('cap_ai_optimizer.tokens.prompt_estimated_after', e.tokens?.promptEstimatedAfter)
|
|
100
|
+
set('cap_ai_optimizer.tokens.cached', e.tokens?.cached)
|
|
101
|
+
set('cap_ai_optimizer.savings.estimated_tokens', e.savings?.estimatedTokens)
|
|
102
|
+
set('cap_ai_optimizer.cache_hit', e.savings?.cacheHit)
|
|
103
|
+
set('cap_ai_optimizer.retries', e.retries)
|
|
104
|
+
set('cap_ai_optimizer.cost_estimate', e.costEstimate)
|
|
105
|
+
set('cap_ai_optimizer.prompt_prefix_changed', e.advisory?.promptPrefixChanged)
|
|
106
|
+
if (e.status === 'error') set('http.response.status_code', e.error?.httpStatus)
|
|
107
|
+
|
|
108
|
+
const span = i.tracer.startSpan(`${e.operation ?? 'chat'}${e.model ? ` ${e.model}` : ''}`, { kind: api.SpanKind.CLIENT, startTime, attributes: spanAttrs }, api.context.active())
|
|
109
|
+
if (e.status === 'error') span.setStatus({ code: api.SpanStatusCode.ERROR, message: e.error?.category })
|
|
110
|
+
span.end(endTime)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
name: 'otel',
|
|
115
|
+
export (event) {
|
|
116
|
+
const i = instruments ?? init()
|
|
117
|
+
const attrs = attributes(event)
|
|
118
|
+
recordMetrics(i, event, attrs)
|
|
119
|
+
recordSpan(i, event, attrs)
|
|
120
|
+
},
|
|
121
|
+
flush () {},
|
|
122
|
+
shutdown () {}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
module.exports = { createOtelExporter }
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Failure classifier — pure function, no I/O.
|
|
4
|
+
*
|
|
5
|
+
* @sap-ai-sdk 2.x throws `ErrorWithCause("Request failed with status code N.")` whose `.cause`
|
|
6
|
+
* is an AxiosError carrying `status`, `code`, `response.headers` and `response.data`
|
|
7
|
+
* (orchestration: `{ error: { code, message, location } }`, Azure OpenAI: `{ error: { code, message, innererror } }`).
|
|
8
|
+
* Deployment-resolution and OAuth failures wrap further levels of `cause`. We therefore walk the
|
|
9
|
+
* whole cause chain (and AggregateError.errors) and collect every signal before deciding.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const CATEGORIES = Object.freeze([
|
|
13
|
+
'RATE_LIMIT', 'CONTENT_FILTER', 'CONTEXT_LENGTH', 'TIMEOUT', 'AUTH',
|
|
14
|
+
'DEPLOYMENT_NOT_FOUND', 'SERVER', 'NETWORK', 'UNKNOWN'
|
|
15
|
+
])
|
|
16
|
+
|
|
17
|
+
const MAX_MESSAGE_LENGTH = 300
|
|
18
|
+
const MAX_DEPTH = 8
|
|
19
|
+
|
|
20
|
+
const RE_CONTENT_FILTER = /content[_ ]?filter|ResponsibleAIPolicyViolation|filtering module|filtered due to safety|content management policy|content_policy_violation/i
|
|
21
|
+
const RE_CONTEXT_LENGTH = /context_length_exceeded|maximum context length|context window|prompt is too long|input is too long|too many tokens|exceeds? the (maximum|max) (number of )?tokens|string_above_max_length/i
|
|
22
|
+
const RE_RATE_LIMIT = /rate[ _-]?limit|too many requests|quota exceeded/i
|
|
23
|
+
const RE_TIMEOUT = /timeout of \d+ms exceeded|timed? ?out|deadline exceeded/i
|
|
24
|
+
const RE_DEPLOYMENT = /no deployment matched|deployment not found|DeploymentNotFound|has no deployment url/i
|
|
25
|
+
const RE_AUTH = /unauthori[sz]ed|forbidden|invalid[_ ]client|could not fetch client credentials token/i
|
|
26
|
+
const RE_NETWORK = /socket hang up|network error|fetch failed|getaddrinfo|connect ECONN/i
|
|
27
|
+
|
|
28
|
+
const TIMEOUT_CODES = new Set(['ECONNABORTED', 'ETIMEDOUT', 'ESOCKETTIMEDOUT', 'ERR_TIMEOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT'])
|
|
29
|
+
const NETWORK_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH', 'ENETDOWN', 'ERR_NETWORK', 'UND_ERR_SOCKET'])
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {object} ClassifiedError
|
|
33
|
+
* @property {string} category one of CATEGORIES
|
|
34
|
+
* @property {number|null} httpStatus
|
|
35
|
+
* @property {string|null} code provider or system error code
|
|
36
|
+
* @property {string} message truncated, never contains request content from us
|
|
37
|
+
* @property {number|null} retryAfterMs parsed Retry-After header, if present
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {unknown} err
|
|
42
|
+
* @param {{ now?: number }} [opts] `now` makes HTTP-date Retry-After parsing deterministic in tests
|
|
43
|
+
* @returns {ClassifiedError}
|
|
44
|
+
*/
|
|
45
|
+
function classifyError (err, { now = Date.now() } = {}) {
|
|
46
|
+
const s = collectSignals(err)
|
|
47
|
+
const status = s.statuses[0] ?? null
|
|
48
|
+
const hasStatus = (pred) => s.statuses.some(pred)
|
|
49
|
+
const text = s.texts.join(' | ')
|
|
50
|
+
const codesText = s.codes.join(' ')
|
|
51
|
+
|
|
52
|
+
let category = 'UNKNOWN'
|
|
53
|
+
// Content-related 400s first — they are the most specific signals.
|
|
54
|
+
if (RE_CONTENT_FILTER.test(codesText) || RE_CONTENT_FILTER.test(text) || s.locations.some(l => /filter/i.test(l))) {
|
|
55
|
+
category = 'CONTENT_FILTER'
|
|
56
|
+
} else if (RE_CONTEXT_LENGTH.test(codesText) || RE_CONTEXT_LENGTH.test(text) || hasStatus(x => x === 413)) {
|
|
57
|
+
category = 'CONTEXT_LENGTH'
|
|
58
|
+
} else if (hasStatus(x => x === 429) || RE_RATE_LIMIT.test(text)) {
|
|
59
|
+
category = 'RATE_LIMIT'
|
|
60
|
+
} else if (hasStatus(x => x === 401 || x === 403)) {
|
|
61
|
+
category = 'AUTH'
|
|
62
|
+
} else if (hasStatus(x => x === 404) || RE_DEPLOYMENT.test(text) || RE_DEPLOYMENT.test(codesText)) {
|
|
63
|
+
category = 'DEPLOYMENT_NOT_FOUND'
|
|
64
|
+
} else if (hasStatus(x => x === 408 || x === 504) || s.codes.some(c => TIMEOUT_CODES.has(c)) ||
|
|
65
|
+
s.names.some(n => n === 'TimeoutError') || RE_TIMEOUT.test(text)) {
|
|
66
|
+
category = 'TIMEOUT'
|
|
67
|
+
} else if (hasStatus(x => x >= 500 && x <= 599)) {
|
|
68
|
+
category = 'SERVER'
|
|
69
|
+
} else if (s.codes.some(c => NETWORK_CODES.has(c)) || RE_NETWORK.test(text)) {
|
|
70
|
+
category = 'NETWORK'
|
|
71
|
+
} else if (RE_AUTH.test(text)) {
|
|
72
|
+
category = 'AUTH'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
category,
|
|
77
|
+
httpStatus: status,
|
|
78
|
+
code: s.providerCodes[0] ?? s.codes[0] ?? null,
|
|
79
|
+
message: truncate(s.providerMessages[0] ?? s.texts.find(Boolean) ?? String(err ?? 'Unknown error')),
|
|
80
|
+
retryAfterMs: parseRetryAfter(s.retryAfter, now)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function collectSignals (root) {
|
|
85
|
+
const s = { statuses: [], codes: [], providerCodes: [], texts: [], providerMessages: [], locations: [], names: [], retryAfter: null }
|
|
86
|
+
const seen = new Set()
|
|
87
|
+
const queue = [{ e: root, depth: 0 }]
|
|
88
|
+
while (queue.length) {
|
|
89
|
+
const { e, depth } = queue.shift()
|
|
90
|
+
if (e == null || depth > MAX_DEPTH || seen.has(e)) continue
|
|
91
|
+
if (typeof e !== 'object' && typeof e !== 'function') { s.texts.push(String(e)); continue }
|
|
92
|
+
seen.add(e)
|
|
93
|
+
|
|
94
|
+
pushNum(s.statuses, e.status, e.statusCode, e.httpStatus, e.response?.status)
|
|
95
|
+
if (typeof e.code === 'string' && e.code) s.codes.push(e.code)
|
|
96
|
+
if (typeof e.name === 'string') s.names.push(e.name)
|
|
97
|
+
if (typeof e.message === 'string' && e.message) s.texts.push(e.message)
|
|
98
|
+
s.retryAfter ??= header(e.response?.headers, 'retry-after') ?? header(e.headers, 'retry-after')
|
|
99
|
+
|
|
100
|
+
const body = parseBody(e.response?.data ?? e.body)
|
|
101
|
+
const errors = providerErrors(body)
|
|
102
|
+
// a non-JSON body (e.g. a gateway's "Payload Too Large") is the most useful message we have
|
|
103
|
+
if (!errors.length && typeof body?.message === 'string' && body.message) {
|
|
104
|
+
s.providerMessages.push(body.message)
|
|
105
|
+
s.texts.push(body.message)
|
|
106
|
+
}
|
|
107
|
+
for (const pe of errors) {
|
|
108
|
+
if (pe.code != null) {
|
|
109
|
+
const code = String(pe.code)
|
|
110
|
+
s.providerCodes.push(code)
|
|
111
|
+
s.codes.push(code)
|
|
112
|
+
if (/^\d{3}$/.test(code)) pushNum(s.statuses, Number(code))
|
|
113
|
+
}
|
|
114
|
+
if (pe.innererror?.code) s.codes.push(String(pe.innererror.code))
|
|
115
|
+
if (typeof pe.message === 'string') { s.providerMessages.push(pe.message); s.texts.push(pe.message) }
|
|
116
|
+
if (typeof pe.location === 'string') s.locations.push(pe.location)
|
|
117
|
+
}
|
|
118
|
+
if (typeof body?.error_description === 'string') s.texts.push(body.error_description)
|
|
119
|
+
|
|
120
|
+
queue.push({ e: e.cause, depth: depth + 1 })
|
|
121
|
+
if (Array.isArray(e.errors)) for (const x of e.errors) queue.push({ e: x, depth: depth + 1 })
|
|
122
|
+
}
|
|
123
|
+
return s
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function providerErrors (body) {
|
|
127
|
+
if (!body || typeof body !== 'object') return []
|
|
128
|
+
const err = body.error
|
|
129
|
+
if (Array.isArray(err)) return err.filter(x => x && typeof x === 'object')
|
|
130
|
+
if (err && typeof err === 'object') return [err]
|
|
131
|
+
if (typeof body.code !== 'undefined' && typeof body.message === 'string') return [body]
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseBody (data) {
|
|
136
|
+
if (typeof data === 'string') {
|
|
137
|
+
try { return JSON.parse(data) } catch { return { message: data } }
|
|
138
|
+
}
|
|
139
|
+
if (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) return parseBody(data.toString('utf8'))
|
|
140
|
+
return data
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function header (headers, name) {
|
|
144
|
+
if (!headers) return null
|
|
145
|
+
if (typeof headers.get === 'function') return headers.get(name) ?? null
|
|
146
|
+
const key = Object.keys(headers).find(k => k.toLowerCase() === name)
|
|
147
|
+
return key ? headers[key] : null
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pushNum (arr, ...values) {
|
|
151
|
+
for (const v of values) {
|
|
152
|
+
const n = typeof v === 'string' ? Number(v) : v
|
|
153
|
+
if (Number.isInteger(n) && n >= 100 && n <= 599 && !arr.includes(n)) arr.push(n)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {string|number|null} value seconds or HTTP-date
|
|
159
|
+
* @param {number} now
|
|
160
|
+
* @returns {number|null} milliseconds
|
|
161
|
+
*/
|
|
162
|
+
function parseRetryAfter (value, now) {
|
|
163
|
+
if (value == null || value === '') return null
|
|
164
|
+
const secs = Number(value)
|
|
165
|
+
if (Number.isFinite(secs)) return Math.max(0, Math.round(secs * 1000))
|
|
166
|
+
const at = Date.parse(String(value))
|
|
167
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function truncate (msg) {
|
|
171
|
+
const str = String(msg)
|
|
172
|
+
return str.length > MAX_MESSAGE_LENGTH ? `${str.slice(0, MAX_MESSAGE_LENGTH - 1)}…` : str
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
module.exports = { classifyError, parseRetryAfter, CATEGORIES }
|