@viveka10/cap-ai-token-optimizer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 }