@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.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +746 -2
- package/SECURITY.md +39 -0
- package/cds-plugin.js +50 -0
- package/db/token-usage-log.cds +44 -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 +131 -4
- package/srv/admin-service.cds +83 -0
- package/srv/admin-service.js +41 -0
- package/srv/admin-usage-log.cds +8 -0
package/lib/optimizer.js
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* TokenOptimizer: wraps @sap-ai-sdk clients, optimizes prompts, measures every call and reports it.
|
|
4
|
+
*
|
|
5
|
+
* Modes:
|
|
6
|
+
* - active: send the optimized request; serve exact-match cache hits without calling AI Core.
|
|
7
|
+
* - shadow: run the pipeline and cache lookup, record what would have been saved, but send
|
|
8
|
+
* the original request (and never serve from cache).
|
|
9
|
+
* - off: no optimization and no cache; calls are still measured.
|
|
10
|
+
*
|
|
11
|
+
* Rule: nothing the optimizer does may break the AI call. If the pipeline fails, the original
|
|
12
|
+
* request is sent. Only errors thrown by the SDK itself reach the caller, unchanged.
|
|
13
|
+
*/
|
|
14
|
+
const { resolveConfig, forUseCase } = require('./config')
|
|
15
|
+
const { MetricsCollector } = require('./metrics/collector')
|
|
16
|
+
const { buildEvent, estimateCost, usageToTokens, cdsContextInfo } = require('./metrics/events')
|
|
17
|
+
const { classifyError } = require('./metrics/classifier')
|
|
18
|
+
const { createExporters } = require('./exporters')
|
|
19
|
+
const { estimateMessages } = require('./tokens/estimator')
|
|
20
|
+
const { fingerprint, sha256, stableStringify } = require('./util/hash')
|
|
21
|
+
const { detectAdapter, withTimeout, serializeResponse, rememberResponseClass, rehydrateResponse } = require('./client/adapters')
|
|
22
|
+
const { runPipeline } = require('./pipeline')
|
|
23
|
+
const { untag, isSystem } = require('./pipeline/messages')
|
|
24
|
+
const { MemoryCacheStore, cacheKey, isCacheable } = require('./cache')
|
|
25
|
+
const { loadPeer } = require('./util/peer')
|
|
26
|
+
const instrument = require('./client/instrument')
|
|
27
|
+
const log = require('./util/log')
|
|
28
|
+
|
|
29
|
+
const WRAPPED = Symbol.for('cap-ai-token-optimizer.wrapped')
|
|
30
|
+
const LANGCHAIN_WRAPPED = Symbol.for('cap-ai-token-optimizer.langchain')
|
|
31
|
+
const MAX_PREFIXES = 1000
|
|
32
|
+
const ADVISORY_LOG_INTERVAL_MS = 10 * 60 * 1000
|
|
33
|
+
|
|
34
|
+
class TokenOptimizer {
|
|
35
|
+
/**
|
|
36
|
+
* @param {object|boolean} [config] user configuration (same shape as cds.requires.tokenOptimizer)
|
|
37
|
+
* @param {object} [deps] injectable dependencies (exporter options, sleep) for tests
|
|
38
|
+
*/
|
|
39
|
+
constructor (config, deps = {}) {
|
|
40
|
+
this.collector = new MetricsCollector()
|
|
41
|
+
this._deps = deps
|
|
42
|
+
this._sleep = deps.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)))
|
|
43
|
+
this._configured = false
|
|
44
|
+
this.cache = null
|
|
45
|
+
// last prompt prefix (system + tools) per use case and model, for the prompt-caching advisory
|
|
46
|
+
this._prefixes = new Map()
|
|
47
|
+
if (config !== undefined) this.configure(config)
|
|
48
|
+
else this.config = resolveConfig(undefined, log)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* (Re)configure: resolve config, replace exporters and cache store, restart the flush timer.
|
|
53
|
+
* Existing exporters are shut down (flushed) in the background.
|
|
54
|
+
* @param {object|boolean} [config]
|
|
55
|
+
*/
|
|
56
|
+
configure (config) {
|
|
57
|
+
const previous = this.collector.exporters
|
|
58
|
+
this.config = resolveConfig(config, log)
|
|
59
|
+
this.collector.exporters = []
|
|
60
|
+
if (previous.length) {
|
|
61
|
+
Promise.all(previous.map(x => Promise.resolve().then(() => x.shutdown?.()).catch(() => {}))).catch(() => {})
|
|
62
|
+
}
|
|
63
|
+
for (const exporter of createExporters(this.config, this._deps)) this.collector.addExporter(exporter)
|
|
64
|
+
this.cache = this.config.cache.store ?? new MemoryCacheStore({ maxEntries: this.config.cache.maxEntries })
|
|
65
|
+
this.collector.stop()
|
|
66
|
+
this.collector.start(this.config.metrics.flushIntervalMs)
|
|
67
|
+
this._configured = true
|
|
68
|
+
try {
|
|
69
|
+
const auto = this.config.autoInstrument
|
|
70
|
+
if (auto?.enabled) instrument.enableAuto(this, { useCase: auto.useCase })
|
|
71
|
+
else instrument.disableAuto(this)
|
|
72
|
+
} catch (e) {
|
|
73
|
+
log.warn('autoInstrument could not be set up:', e?.message)
|
|
74
|
+
}
|
|
75
|
+
return this
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run `fn` so that @sap-ai-sdk calls made inside it (by any client, also ones created by
|
|
80
|
+
* libraries) are optimized and measured with `useCase`.
|
|
81
|
+
* @template T
|
|
82
|
+
* @param {string} useCase
|
|
83
|
+
* @param {() => T} fn
|
|
84
|
+
* @param {{ cdsService?: string }} [opts]
|
|
85
|
+
* @returns {T}
|
|
86
|
+
*/
|
|
87
|
+
withUseCase (useCase, fn, opts = {}) {
|
|
88
|
+
this._ensureConfigured()
|
|
89
|
+
return instrument.runInScope(this, { ...opts, useCase }, fn)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Instrument a LangChain chat model from @sap-ai-sdk/langchain (OrchestrationClient,
|
|
94
|
+
* AzureOpenAiChatClient). The model is returned with the same API; its AI Core calls
|
|
95
|
+
* (invoke, batch, stream, bound tools, structured output) are optimized and measured.
|
|
96
|
+
* @template T
|
|
97
|
+
* @param {T} model
|
|
98
|
+
* @param {{ useCase?: string, cdsService?: string }} [opts]
|
|
99
|
+
* @returns {T}
|
|
100
|
+
*/
|
|
101
|
+
wrapLangChain (model, opts = {}) {
|
|
102
|
+
if (model?.[LANGCHAIN_WRAPPED]) return model
|
|
103
|
+
if (typeof model?._generate !== 'function') {
|
|
104
|
+
throw new TypeError('optimizer.wrapLangChain(): expected a LangChain chat model from @sap-ai-sdk/langchain')
|
|
105
|
+
}
|
|
106
|
+
this._ensureConfigured()
|
|
107
|
+
const self = this
|
|
108
|
+
const generate = model._generate
|
|
109
|
+
model._generate = function (...args) {
|
|
110
|
+
return instrument.runInScope(self, opts, () => generate.apply(this, args))
|
|
111
|
+
}
|
|
112
|
+
const stream = model._streamResponseChunks
|
|
113
|
+
if (typeof stream === 'function') {
|
|
114
|
+
// generator bodies run in the context of each next() call, so scope every step
|
|
115
|
+
model._streamResponseChunks = async function * (...args) {
|
|
116
|
+
const it = stream.apply(this, args)
|
|
117
|
+
try {
|
|
118
|
+
for (;;) {
|
|
119
|
+
const r = await instrument.runInScope(self, opts, () => it.next())
|
|
120
|
+
if (r.done) return r.value
|
|
121
|
+
yield r.value
|
|
122
|
+
}
|
|
123
|
+
} finally {
|
|
124
|
+
await it.return?.()
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
Object.defineProperty(model, LANGCHAIN_WRAPPED, { value: true })
|
|
129
|
+
return model
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Configure from cds.env on first use, if configure() was never called. */
|
|
133
|
+
_ensureConfigured () {
|
|
134
|
+
if (this._configured) return
|
|
135
|
+
let cfg
|
|
136
|
+
try { cfg = globalThis.cds?.env?.requires?.tokenOptimizer } catch { /* cds.env not ready */ }
|
|
137
|
+
this.configure(cfg)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Wrap an SDK client. The returned object has the same API as the client; supported
|
|
142
|
+
* methods (OrchestrationClient.chatCompletion/stream, AzureOpenAiChatClient.run/stream)
|
|
143
|
+
* are optimized and measured, everything else is forwarded untouched.
|
|
144
|
+
* @template T
|
|
145
|
+
* @param {T} client
|
|
146
|
+
* @param {{ useCase?: string, cdsService?: string }} [opts]
|
|
147
|
+
* @returns {T}
|
|
148
|
+
*/
|
|
149
|
+
wrap (client, opts = {}) {
|
|
150
|
+
if (client && client[WRAPPED]) return client
|
|
151
|
+
const adapter = detectAdapter(client)
|
|
152
|
+
if (!adapter) {
|
|
153
|
+
throw new TypeError('optimizer.wrap(): unsupported client. Expected an OrchestrationClient or AzureOpenAiChatClient from @sap-ai-sdk.')
|
|
154
|
+
}
|
|
155
|
+
const self = this
|
|
156
|
+
const cache = new Map()
|
|
157
|
+
return new Proxy(client, {
|
|
158
|
+
get (target, prop) {
|
|
159
|
+
if (prop === WRAPPED) return true
|
|
160
|
+
const value = Reflect.get(target, prop, target)
|
|
161
|
+
if (typeof value !== 'function') return value
|
|
162
|
+
if (cache.has(prop)) return cache.get(prop)
|
|
163
|
+
const method = Object.hasOwn(adapter.methods, prop) ? adapter.methods[prop] : null
|
|
164
|
+
const fn = method
|
|
165
|
+
? (...args) => self._call({ adapter, method, methodName: prop, client: target, args, opts })
|
|
166
|
+
: value.bind(target)
|
|
167
|
+
cache.set(prop, fn)
|
|
168
|
+
return fn
|
|
169
|
+
}
|
|
170
|
+
})
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Explicit call without creating a client yourself.
|
|
175
|
+
* Uses the orchestration service by default, or foundation models with `sdk: 'foundation-models'`.
|
|
176
|
+
* @param {import('../index').ChatOptions} opts
|
|
177
|
+
* @returns {Promise<import('../index').ChatResult>}
|
|
178
|
+
*/
|
|
179
|
+
async chat (opts = {}) {
|
|
180
|
+
const { useCase, cdsService, sdk = 'orchestration', model, messages, tools, maxTokens, temperature, params = {}, deploymentId, resourceGroup, requestConfig } = opts
|
|
181
|
+
if (!model && !deploymentId) throw new TypeError('optimizer.chat(): `model` is required')
|
|
182
|
+
if (!Array.isArray(messages) || !messages.length) throw new TypeError('optimizer.chat(): `messages` must be a non-empty array')
|
|
183
|
+
const modelParams = { ...params }
|
|
184
|
+
if (maxTokens != null) modelParams.max_tokens = maxTokens
|
|
185
|
+
if (temperature != null) modelParams.temperature = temperature
|
|
186
|
+
|
|
187
|
+
let client, response
|
|
188
|
+
if (sdk === 'foundation-models') {
|
|
189
|
+
const { AzureOpenAiChatClient } = loadPeer('@sap-ai-sdk/foundation-models', 'optimizer.chat({ sdk: "foundation-models" })')
|
|
190
|
+
const deployment = deploymentId ? { deploymentId, resourceGroup } : { modelName: model, resourceGroup }
|
|
191
|
+
client = this.wrap(new AzureOpenAiChatClient(deployment), { useCase, cdsService })
|
|
192
|
+
response = await client.run({ messages, ...(tools?.length ? { tools } : {}), ...modelParams }, requestConfig)
|
|
193
|
+
} else if (sdk === 'orchestration') {
|
|
194
|
+
const { OrchestrationClient } = loadPeer('@sap-ai-sdk/orchestration', 'optimizer.chat()')
|
|
195
|
+
const promptTemplating = { model: { name: model, params: modelParams }, ...(tools?.length ? { prompt: { tools } } : {}) }
|
|
196
|
+
const deployment = deploymentId ? { deploymentId, resourceGroup } : (resourceGroup ? { resourceGroup } : undefined)
|
|
197
|
+
client = this.wrap(new OrchestrationClient({ promptTemplating }, deployment), { useCase, cdsService })
|
|
198
|
+
response = await client.chatCompletion({ messages }, requestConfig)
|
|
199
|
+
} else {
|
|
200
|
+
throw new TypeError(`optimizer.chat(): unknown sdk "${sdk}". Use "orchestration" or "foundation-models".`)
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
content: safe(() => response.getContent()) ?? null,
|
|
204
|
+
toolCalls: safe(() => response.getToolCalls()) ?? null,
|
|
205
|
+
finishReason: safe(() => response.getFinishReason()) ?? null,
|
|
206
|
+
usage: safe(() => response.getTokenUsage()) ?? null,
|
|
207
|
+
response
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Aggregated in-memory stats. */
|
|
212
|
+
stats () { return this.collector.stats() }
|
|
213
|
+
|
|
214
|
+
/** Subscribe to events: 'call' (one per AI call). */
|
|
215
|
+
on (type, listener) { this.collector.on(type, listener); return this }
|
|
216
|
+
off (type, listener) { this.collector.off(type, listener); return this }
|
|
217
|
+
once (type, listener) { this.collector.once(type, listener); return this }
|
|
218
|
+
|
|
219
|
+
/** Flush buffering exporters. Never rejects. */
|
|
220
|
+
flush () { return this.collector.flush() }
|
|
221
|
+
|
|
222
|
+
/** Stop timers, flush and shut down exporters. Never rejects. */
|
|
223
|
+
shutdown () { return this.collector.shutdown() }
|
|
224
|
+
|
|
225
|
+
/** Clear in-memory stats. */
|
|
226
|
+
resetStats () { this.collector.reset() }
|
|
227
|
+
|
|
228
|
+
/** Optimize, send and measure one SDK call. */
|
|
229
|
+
async _call ({ adapter, method, methodName, client, args, opts }) {
|
|
230
|
+
this._ensureConfigured()
|
|
231
|
+
const started = performance.now()
|
|
232
|
+
const ctx = await this._prepare({ adapter, method, client, args, opts })
|
|
233
|
+
const cfg = ctx.cfg
|
|
234
|
+
// what we send: the optimized call in active mode, otherwise the original
|
|
235
|
+
const plan = cfg.mode === 'active' && ctx.optimized ? ctx.optimized : { client, args }
|
|
236
|
+
|
|
237
|
+
if (ctx.cacheKey) {
|
|
238
|
+
const entry = await this._cacheGet(ctx.cacheKey)
|
|
239
|
+
if (entry) {
|
|
240
|
+
const response = cfg.mode === 'active' ? safe(() => rehydrateResponse(entry), (e) => log.warn('could not rebuild cached response:', e?.message)) : null
|
|
241
|
+
if (response) {
|
|
242
|
+
safe(() => this._record(ctx, { started, retries: 0, response, cacheEntry: entry }), logRecordError)
|
|
243
|
+
return response
|
|
244
|
+
}
|
|
245
|
+
if (cfg.mode === 'shadow') ctx.wouldHitCache = entry
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const callArgs = safe(() => withTimeout(plan.args, method.requestConfigIndex, cfg.timeoutMs)) ?? plan.args
|
|
250
|
+
let retries = 0
|
|
251
|
+
for (;;) {
|
|
252
|
+
try {
|
|
253
|
+
// the original method: a hooked one would route the call through an optimizer again
|
|
254
|
+
const response = await instrument.originalOf(plan.client[methodName]).apply(plan.client, callArgs)
|
|
255
|
+
if (ctx.cacheKey) this._cacheSet(ctx, response)
|
|
256
|
+
safe(() => this._record(ctx, { started, retries, response }), logRecordError)
|
|
257
|
+
return response
|
|
258
|
+
} catch (err) {
|
|
259
|
+
const classified = safe(() => classifyError(err)) ?? { category: 'UNKNOWN', httpStatus: null, code: null, message: String(err?.message ?? err), retryAfterMs: null }
|
|
260
|
+
const delay = this._retryDelay(cfg, classified, retries)
|
|
261
|
+
if (delay != null) {
|
|
262
|
+
retries++
|
|
263
|
+
log.debug(`retrying after ${classified.category} in ${delay}ms (attempt ${retries})`)
|
|
264
|
+
await this._sleep(delay)
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
safe(() => this._record(ctx, { started, retries, error: classified }), logRecordError)
|
|
268
|
+
throw err
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Everything known before the call: call info, estimates, pipeline result and cache key.
|
|
275
|
+
* Never throws: a failure leaves the call unoptimized.
|
|
276
|
+
*/
|
|
277
|
+
async _prepare ({ adapter, method, client, args, opts }) {
|
|
278
|
+
const ctx = { cfg: this.config, sdk: adapter.sdk, operation: method.operation, useCase: opts.useCase, cdsService: opts.cdsService }
|
|
279
|
+
try {
|
|
280
|
+
ctx.cfg = forUseCase(this.config, opts.useCase)
|
|
281
|
+
ctx.context = cdsContextInfo()
|
|
282
|
+
const info = ctx.info = adapter.extract(client, args)
|
|
283
|
+
ctx.estimate = estimateMessages(info.messages, { model: info.model, tools: info.tools })
|
|
284
|
+
Object.assign(ctx, fingerprint(info.messages.map(untag)))
|
|
285
|
+
} catch (e) {
|
|
286
|
+
log.warn('could not read call details:', e?.message)
|
|
287
|
+
return ctx
|
|
288
|
+
}
|
|
289
|
+
if (ctx.cfg.mode === 'off' || method.operation !== 'chat') return ctx
|
|
290
|
+
|
|
291
|
+
const { info } = ctx
|
|
292
|
+
let result = null
|
|
293
|
+
try {
|
|
294
|
+
result = await runPipeline({ messages: info.messages, tools: info.tools, params: info.params, model: info.model, steps: ctx.cfg.steps, editable: info.editable })
|
|
295
|
+
ctx.optimized = adapter.apply(client, args, info, result)
|
|
296
|
+
ctx.pipeline = result
|
|
297
|
+
} catch (e) {
|
|
298
|
+
log.warn('optimizer pipeline failed, sending the original request:', e?.message)
|
|
299
|
+
ctx.pipelineFailed = true
|
|
300
|
+
ctx.optimized = null
|
|
301
|
+
result = null
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
const params = result?.params ?? info.params
|
|
305
|
+
if (isCacheable(ctx.cfg.cache, params)) {
|
|
306
|
+
const material = (ctx.optimized ?? adapter.apply(client, args, info, info)).keyMaterial
|
|
307
|
+
ctx.cacheKey = cacheKey(material, { scope: ctx.cfg.cache.scope, tenant: ctx.context?.tenant, userId: ctx.context?.userId })
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {
|
|
310
|
+
log.warn('could not compute cache key:', e?.message)
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
const sent = ctx.cfg.mode === 'active' && ctx.pipeline ? ctx.pipeline : info
|
|
314
|
+
ctx.prefix = { original: prefixOf(info.messages, info.tools), sent: prefixOf(sent.messages, sent.tools) }
|
|
315
|
+
} catch { /* advisory only */ }
|
|
316
|
+
return ctx
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Prompt-caching advisory: providers cache the start of the prompt (system messages, tools).
|
|
321
|
+
* When it changes between calls of the same use case and model, that cache can't be used.
|
|
322
|
+
* @returns {{ promptPrefixChanged: boolean, changed: string[], causedByOptimizer: boolean } | null}
|
|
323
|
+
*/
|
|
324
|
+
_checkPrefix (ctx) {
|
|
325
|
+
if (!ctx.cfg.advisories?.promptCache || !ctx.prefix) return null
|
|
326
|
+
const key = `${ctx.useCase ?? 'default'}|${ctx.info?.model ?? ''}`
|
|
327
|
+
const prev = this._prefixes.get(key)
|
|
328
|
+
this._prefixes.delete(key)
|
|
329
|
+
this._prefixes.set(key, { ...ctx.prefix, loggedAt: prev?.loggedAt ?? 0 })
|
|
330
|
+
if (this._prefixes.size > MAX_PREFIXES) this._prefixes.delete(this._prefixes.keys().next().value)
|
|
331
|
+
if (!prev) return { promptPrefixChanged: false, changed: [], causedByOptimizer: false }
|
|
332
|
+
|
|
333
|
+
const changed = ['system', 'tools'].filter(part => prev.sent[part] !== ctx.prefix.sent[part])
|
|
334
|
+
if (!changed.length) return { promptPrefixChanged: false, changed, causedByOptimizer: false }
|
|
335
|
+
// the caller sent the same prefix as last time, so the optimizer changed it
|
|
336
|
+
const causedByOptimizer = changed.every(part => prev.original[part] === ctx.prefix.original[part])
|
|
337
|
+
const entry = this._prefixes.get(key)
|
|
338
|
+
const now = Date.now()
|
|
339
|
+
if (now - entry.loggedAt > ADVISORY_LOG_INTERVAL_MS) {
|
|
340
|
+
entry.loggedAt = now
|
|
341
|
+
log.warn(`prompt prefix (${changed.join(', ')}) changed for use case "${ctx.useCase ?? 'default'}"${causedByOptimizer ? ' by the optimizer' : ''}; ` +
|
|
342
|
+
'provider prompt caching cannot be used. Keep system prompts and tool definitions stable per use case.')
|
|
343
|
+
}
|
|
344
|
+
const advisory = { promptPrefixChanged: true, changed, causedByOptimizer }
|
|
345
|
+
this.collector.notify('advisory', { type: 'PROMPT_PREFIX_CHANGED', useCase: ctx.useCase ?? 'default', model: ctx.info?.model ?? null, ...advisory })
|
|
346
|
+
return advisory
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
async _cacheGet (key) {
|
|
350
|
+
try {
|
|
351
|
+
return (await this.cache.get(key)) ?? null
|
|
352
|
+
} catch (e) {
|
|
353
|
+
log.warn('cache read failed:', e?.message)
|
|
354
|
+
return null
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
_cacheSet (ctx, response) {
|
|
359
|
+
try {
|
|
360
|
+
rememberResponseClass(ctx.sdk, response)
|
|
361
|
+
const entry = serializeResponse(ctx.sdk, response)
|
|
362
|
+
if (!entry) return
|
|
363
|
+
Promise.resolve(this.cache.set(ctx.cacheKey, entry, ctx.cfg.cache.ttlSeconds * 1000))
|
|
364
|
+
.catch(e => log.warn('cache write failed:', e?.message))
|
|
365
|
+
} catch (e) {
|
|
366
|
+
log.warn('cache write failed:', e?.message)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_retryDelay (cfg, classified, retries) {
|
|
371
|
+
const r = cfg.retry
|
|
372
|
+
if (!r?.enabled || retries >= r.maxRetries || !r.on?.includes(classified.category)) return null
|
|
373
|
+
if (classified.retryAfterMs != null) return Math.min(classified.retryAfterMs, r.maxDelayMs)
|
|
374
|
+
const backoff = r.baseDelayMs * 2 ** retries
|
|
375
|
+
return Math.min(r.maxDelayMs, Math.round(backoff / 2 + Math.random() * backoff / 2))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
_record (ctx, { started, retries, response, error, cacheEntry }) {
|
|
379
|
+
const latencyMs = Math.round(performance.now() - started)
|
|
380
|
+
const cfg = ctx.cfg
|
|
381
|
+
const info = ctx.info ?? {}
|
|
382
|
+
const pipeline = ctx.pipeline
|
|
383
|
+
const before = ctx.estimate?.tokens ?? null
|
|
384
|
+
const after = pipeline ? pipeline.estimateAfter : before
|
|
385
|
+
|
|
386
|
+
// A cache hit consumed no AI Core tokens; what it saved is the cached call's usage.
|
|
387
|
+
// In shadow mode a would-be hit is recorded as savings, the usage is real.
|
|
388
|
+
const usage = cacheEntry
|
|
389
|
+
? { prompt: 0, completion: 0, total: 0, cached: 0 }
|
|
390
|
+
: usageToTokens(response ? safe(() => response.getTokenUsage?.()) : null)
|
|
391
|
+
const byStep = { ...(pipeline?.byStep ?? {}) }
|
|
392
|
+
const hitEntry = cacheEntry ?? ctx.wouldHitCache
|
|
393
|
+
if (hitEntry) {
|
|
394
|
+
const saved = usageToTokens(hitEntry.data?.final_result?.usage ?? hitEntry.data?.usage)
|
|
395
|
+
byStep.cache = saved.total ?? after ?? 0
|
|
396
|
+
}
|
|
397
|
+
const promptSaved = before != null && after != null ? before - after : 0
|
|
398
|
+
const model = info.model ?? null
|
|
399
|
+
const notes = [...(pipeline?.notes ?? [])]
|
|
400
|
+
if (ctx.pipelineFailed) notes.push('pipeline failed; original request sent')
|
|
401
|
+
const advisory = cacheEntry ? null : safe(() => this._checkPrefix(ctx))
|
|
402
|
+
|
|
403
|
+
const event = buildEvent({
|
|
404
|
+
tenant: ctx.context?.tenant,
|
|
405
|
+
userId: ctx.context?.userId,
|
|
406
|
+
cdsService: ctx.cdsService ?? ctx.context?.cdsService,
|
|
407
|
+
useCase: ctx.useCase,
|
|
408
|
+
mode: cfg.mode,
|
|
409
|
+
model,
|
|
410
|
+
deploymentId: info.deploymentId,
|
|
411
|
+
resourceGroup: info.resourceGroup,
|
|
412
|
+
sdk: ctx.sdk,
|
|
413
|
+
operation: ctx.operation,
|
|
414
|
+
requestId: response && !cacheEntry ? safe(() => response.getRequestId?.()) : null,
|
|
415
|
+
finishReason: response ? safe(() => response.getFinishReason?.()) : null,
|
|
416
|
+
estimateMethod: ctx.estimate?.method,
|
|
417
|
+
tokens: { promptEstimatedBefore: before, promptEstimatedAfter: after, ...usage },
|
|
418
|
+
savings: {
|
|
419
|
+
estimatedTokens: promptSaved + (byStep.cache ?? 0),
|
|
420
|
+
byStep,
|
|
421
|
+
cacheHit: !!hitEntry,
|
|
422
|
+
notes,
|
|
423
|
+
failed: !!ctx.pipelineFailed
|
|
424
|
+
},
|
|
425
|
+
costEstimate: estimateCost(model, usage, cfg.pricing),
|
|
426
|
+
latencyMs,
|
|
427
|
+
retries,
|
|
428
|
+
status: error ? 'error' : 'success',
|
|
429
|
+
error,
|
|
430
|
+
promptHash: ctx.promptHash,
|
|
431
|
+
promptLength: ctx.promptLength,
|
|
432
|
+
advisory,
|
|
433
|
+
content: cfg.captureContent ? captured(ctx, response) : undefined
|
|
434
|
+
}, { userHashSalt: cfg.metrics?.userHashSalt })
|
|
435
|
+
this.collector.record(event)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Raw content, only when captureContent is explicitly enabled. */
|
|
440
|
+
function captured (ctx, response) {
|
|
441
|
+
const sent = ctx.cfg.mode === 'active' && ctx.pipeline ? ctx.pipeline.messages : ctx.info?.messages
|
|
442
|
+
return {
|
|
443
|
+
messages: (sent ?? []).map(untag),
|
|
444
|
+
completion: response ? safe(() => response.getContent?.()) ?? null : null
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** Hashes of the parts of a prompt that provider prompt caching depends on. */
|
|
449
|
+
function prefixOf (messages, tools) {
|
|
450
|
+
const system = (messages ?? []).filter(isSystem).map(m => m.content)
|
|
451
|
+
return { system: sha256(stableStringify(system)), tools: sha256(stableStringify(tools ?? [])) }
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const logRecordError = (e) => log.warn('could not record call metrics:', e?.message)
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Run `fn`, returning undefined instead of throwing.
|
|
458
|
+
* @template T
|
|
459
|
+
* @param {() => T} fn
|
|
460
|
+
* @param {(e: any) => void} [onError]
|
|
461
|
+
* @returns {T|undefined}
|
|
462
|
+
*/
|
|
463
|
+
function safe (fn, onError) {
|
|
464
|
+
try { return fn() } catch (e) { onError?.(e); return undefined }
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
module.exports = { TokenOptimizer, WRAPPED }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Optimization pipeline. Steps run in this order: normalize, dedupe, history, tools, outputCap.
|
|
4
|
+
* (The response cache runs around the call, see lib/cache.)
|
|
5
|
+
*
|
|
6
|
+
* Each step receives { messages, tools, params, model } and returns any of
|
|
7
|
+
* { messages, tools, params, notes }. The runner measures what each step saved
|
|
8
|
+
* (estimated prompt tokens before minus after), so every step reports its savings the same way.
|
|
9
|
+
* A step that throws aborts the whole pipeline; the caller then sends the original request.
|
|
10
|
+
*/
|
|
11
|
+
const { normalize } = require('./steps/normalize')
|
|
12
|
+
const { dedupe } = require('./steps/dedupe')
|
|
13
|
+
const { history } = require('./steps/history')
|
|
14
|
+
const { tools } = require('./steps/tools')
|
|
15
|
+
const { outputCap } = require('./steps/outputCap')
|
|
16
|
+
const { createMemoEstimator } = require('../tokens/estimator')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {object} StepInput
|
|
20
|
+
* @property {object[]} messages
|
|
21
|
+
* @property {object[]|undefined} tools
|
|
22
|
+
* @property {object} params
|
|
23
|
+
* @property {string|null} model
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const STEPS = [
|
|
27
|
+
{ name: 'normalize', run: normalize },
|
|
28
|
+
{ name: 'dedupe', run: dedupe },
|
|
29
|
+
{ name: 'history', run: history },
|
|
30
|
+
{ name: 'tools', run: tools, needs: 'tools' },
|
|
31
|
+
{ name: 'outputCap', run: outputCap, needs: 'params' }
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Step config → options object, or null when the step is disabled.
|
|
36
|
+
* Accepts false/true/object; `{ enabled: false }` also disables.
|
|
37
|
+
*/
|
|
38
|
+
function stepOptions (value) {
|
|
39
|
+
if (value === true) return {}
|
|
40
|
+
if (value && typeof value === 'object' && value.enabled !== false) return value
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} input
|
|
46
|
+
* @param {object[]} input.messages
|
|
47
|
+
* @param {object[]} [input.tools]
|
|
48
|
+
* @param {object} [input.params]
|
|
49
|
+
* @param {string|null} [input.model]
|
|
50
|
+
* @param {object} input.steps steps config
|
|
51
|
+
* @param {{ tools?: boolean, params?: boolean }} [input.editable] whether tools / params can be changed for this client
|
|
52
|
+
*/
|
|
53
|
+
async function runPipeline ({ messages, tools: toolDefs, params = {}, model = null, steps = {}, editable = { tools: true, params: true } }) {
|
|
54
|
+
const est = createMemoEstimator(model)
|
|
55
|
+
let state = { messages: messages ?? [], tools: toolDefs, params: params ?? {} }
|
|
56
|
+
const byStep = {}
|
|
57
|
+
const notes = []
|
|
58
|
+
const before = est.estimate(state.messages, state.tools).tokens
|
|
59
|
+
|
|
60
|
+
for (const step of STEPS) {
|
|
61
|
+
const options = stepOptions(steps[step.name])
|
|
62
|
+
if (!options) continue
|
|
63
|
+
if (step.needs && editable[step.needs] === false) {
|
|
64
|
+
notes.push(`${step.name}: skipped, the client's ${step.needs} are fixed`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const start = est.estimate(state.messages, state.tools).tokens
|
|
68
|
+
const result = await step.run({ ...state, model }, options)
|
|
69
|
+
state = {
|
|
70
|
+
messages: result?.messages ?? state.messages,
|
|
71
|
+
tools: result && 'tools' in result ? result.tools : state.tools,
|
|
72
|
+
params: result?.params ?? state.params
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(state.messages)) throw new Error(`step ${step.name} returned invalid messages`)
|
|
75
|
+
byStep[step.name] = start - est.estimate(state.messages, state.tools).tokens
|
|
76
|
+
for (const n of result?.notes ?? []) notes.push(`${step.name}: ${n}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const after = est.estimate(state.messages, state.tools).tokens
|
|
80
|
+
return {
|
|
81
|
+
...state,
|
|
82
|
+
byStep,
|
|
83
|
+
notes,
|
|
84
|
+
estimateBefore: before,
|
|
85
|
+
estimateAfter: after,
|
|
86
|
+
savedTokensEstimate: before - after
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { runPipeline, stepOptions, STEPS }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Message helpers shared by the pipeline steps.
|
|
4
|
+
*
|
|
5
|
+
* Messages carry their origin under a symbol key (not serialised, so it never reaches
|
|
6
|
+
* AI Core). Orchestration template messages are "locked": they are part of the client's
|
|
7
|
+
* fixed config, so steps must neither change nor drop them.
|
|
8
|
+
*/
|
|
9
|
+
const ORIGIN = Symbol('cap-ai-token-optimizer.origin')
|
|
10
|
+
|
|
11
|
+
const SYSTEM_ROLES = new Set(['system', 'developer'])
|
|
12
|
+
|
|
13
|
+
/** @param {object} m */
|
|
14
|
+
const isLocked = (m) => m?.[ORIGIN] === 'template'
|
|
15
|
+
|
|
16
|
+
/** @param {object} m */
|
|
17
|
+
const isSystem = (m) => SYSTEM_ROLES.has(m?.role)
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shallow copy of a message tagged with its origin.
|
|
21
|
+
* @param {object} m
|
|
22
|
+
* @param {string} origin
|
|
23
|
+
*/
|
|
24
|
+
const tag = (m, origin) => ({ ...m, [ORIGIN]: origin })
|
|
25
|
+
|
|
26
|
+
/** Copy without the origin tag. */
|
|
27
|
+
function untag (m) {
|
|
28
|
+
if (!m || typeof m !== 'object') return m
|
|
29
|
+
const { [ORIGIN]: _origin, ...rest } = m
|
|
30
|
+
return rest
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Apply `fn` to every text of a message (string content or text parts).
|
|
35
|
+
* Returns the same object when nothing changed, so unchanged messages keep their identity.
|
|
36
|
+
* @param {object} m
|
|
37
|
+
* @param {(text: string) => string} fn
|
|
38
|
+
*/
|
|
39
|
+
function mapText (m, fn) {
|
|
40
|
+
if (!m || isLocked(m)) return m
|
|
41
|
+
if (typeof m.content === 'string') {
|
|
42
|
+
const next = fn(m.content)
|
|
43
|
+
return next === m.content ? m : { ...m, content: next }
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(m.content)) {
|
|
46
|
+
let changed = false
|
|
47
|
+
const content = m.content.map(p => {
|
|
48
|
+
if (p && p.type === 'text' && typeof p.text === 'string') {
|
|
49
|
+
const text = fn(p.text)
|
|
50
|
+
if (text !== p.text) { changed = true; return { ...p, text } }
|
|
51
|
+
}
|
|
52
|
+
return p
|
|
53
|
+
})
|
|
54
|
+
return changed ? { ...m, content } : m
|
|
55
|
+
}
|
|
56
|
+
return m
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Name of a tool definition (OpenAI-style function tools). */
|
|
60
|
+
const toolName = (t) => t?.function?.name ?? t?.name ?? null
|
|
61
|
+
|
|
62
|
+
module.exports = { ORIGIN, isLocked, isSystem, tag, untag, mapText, toolName }
|