@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.
@@ -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 }