@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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* SDK adapters: read a call's messages, tools and params from each supported
|
|
4
|
+
* @sap-ai-sdk client method, and build the (optimized) call to send. Verified against
|
|
5
|
+
* @sap-ai-sdk/* 2.16.0:
|
|
6
|
+
*
|
|
7
|
+
* OrchestrationClient.chatCompletion(request?, requestConfig?)
|
|
8
|
+
* OrchestrationClient.stream(request?, signal?, options?, requestConfig?)
|
|
9
|
+
* AzureOpenAiChatClient.run(request, requestConfig?)
|
|
10
|
+
* AzureOpenAiChatClient.stream(request, signal?, requestConfig?)
|
|
11
|
+
*
|
|
12
|
+
* OrchestrationClient fixes model, params and tools at construction time and keeps them in
|
|
13
|
+
* its (TypeScript-private, runtime-visible) `config`, `deploymentConfig` and `destination`
|
|
14
|
+
* fields. When the pipeline changes tools or params, we construct a sibling client with a
|
|
15
|
+
* modified copy of the config, so the caller's client is never mutated and concurrent calls
|
|
16
|
+
* stay independent. The parsed config is accepted again by the constructor.
|
|
17
|
+
*/
|
|
18
|
+
const { ORIGIN, tag, untag } = require('../pipeline/messages')
|
|
19
|
+
const { stableStringify, sha256 } = require('../util/hash')
|
|
20
|
+
const { loadPeer } = require('../util/peer')
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} CallInfo
|
|
24
|
+
* @property {string|null} model
|
|
25
|
+
* @property {string|null} deploymentId
|
|
26
|
+
* @property {string|null} resourceGroup
|
|
27
|
+
* @property {Array<object>} messages all messages that reach the model, in order, tagged with their origin
|
|
28
|
+
* @property {Array<object>|undefined} tools
|
|
29
|
+
* @property {object} params model parameters (max_tokens, temperature, …)
|
|
30
|
+
* @property {{ tools: boolean, params: boolean }} editable whether tools / params can be changed per call
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const DERIVED_CLIENTS_PER_CLIENT = 32
|
|
34
|
+
const derivedClients = new WeakMap()
|
|
35
|
+
|
|
36
|
+
const sameJson = (a, b) => stableStringify(a) === stableStringify(b)
|
|
37
|
+
const byOrigin = (messages, ...origins) => messages.filter(m => origins.includes(m[ORIGIN])).map(untag)
|
|
38
|
+
|
|
39
|
+
const orchestration = {
|
|
40
|
+
sdk: 'orchestration',
|
|
41
|
+
/** method name → { operation, requestConfigIndex } */
|
|
42
|
+
methods: {
|
|
43
|
+
chatCompletion: { operation: 'chat', requestConfigIndex: 1 },
|
|
44
|
+
stream: { operation: 'stream', requestConfigIndex: 3 }
|
|
45
|
+
},
|
|
46
|
+
matches: (client) => typeof client?.chatCompletion === 'function',
|
|
47
|
+
|
|
48
|
+
/** @returns {CallInfo} */
|
|
49
|
+
extract (client, args) {
|
|
50
|
+
const request = args[0] ?? {}
|
|
51
|
+
const config = client.config
|
|
52
|
+
const module = firstModuleConfig(config)
|
|
53
|
+
const tpl = module?.promptTemplating
|
|
54
|
+
const template = Array.isArray(tpl?.prompt?.template) ? tpl.prompt.template : []
|
|
55
|
+
const dc = client.deploymentConfig ?? {}
|
|
56
|
+
// only a single inline module config of a real client class can be modified per call
|
|
57
|
+
const single = isInlineConfig(config) && canDerive(client)
|
|
58
|
+
return {
|
|
59
|
+
model: tpl?.model?.name ?? null,
|
|
60
|
+
deploymentId: dc.deploymentId ?? null,
|
|
61
|
+
resourceGroup: dc.resourceGroup ?? null,
|
|
62
|
+
// orchestration sends messages_history first, then template + request messages
|
|
63
|
+
messages: [
|
|
64
|
+
...(request.messagesHistory ?? []).map(m => tag(m, 'history')),
|
|
65
|
+
...template.map(m => tag(m, 'template')),
|
|
66
|
+
...(request.messages ?? []).map(m => tag(m, 'request'))
|
|
67
|
+
],
|
|
68
|
+
tools: tpl?.prompt?.tools,
|
|
69
|
+
params: tpl?.model?.params ?? {},
|
|
70
|
+
editable: { tools: single, params: single }
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Build the call that sends `result` (the pipeline output).
|
|
76
|
+
* @returns {{ client: object, args: any[], keyMaterial: object }}
|
|
77
|
+
*/
|
|
78
|
+
apply (client, args, info, result) {
|
|
79
|
+
const request = args[0] ?? {}
|
|
80
|
+
const nextRequest = { ...request }
|
|
81
|
+
const history = byOrigin(result.messages, 'history')
|
|
82
|
+
const messages = result.messages.filter(m => m[ORIGIN] !== 'history' && m[ORIGIN] !== 'template').map(untag)
|
|
83
|
+
if (history.length) nextRequest.messagesHistory = history
|
|
84
|
+
else delete nextRequest.messagesHistory
|
|
85
|
+
if (messages.length || request.messages) nextRequest.messages = messages
|
|
86
|
+
|
|
87
|
+
let target = client
|
|
88
|
+
let config = client.config
|
|
89
|
+
const toolsChanged = !sameJson(result.tools, info.tools)
|
|
90
|
+
const paramsChanged = !sameJson(result.params, info.params)
|
|
91
|
+
if ((toolsChanged || paramsChanged) && isInlineConfig(config) && canDerive(client)) {
|
|
92
|
+
const pt = config.promptTemplating
|
|
93
|
+
const prompt = { ...(pt.prompt ?? {}) }
|
|
94
|
+
if (result.tools?.length) prompt.tools = result.tools
|
|
95
|
+
else delete prompt.tools
|
|
96
|
+
config = { ...config, promptTemplating: { ...pt, model: { ...pt.model, params: result.params }, prompt } }
|
|
97
|
+
target = derivedClient(client, config)
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
client: target,
|
|
101
|
+
args: [nextRequest, ...args.slice(1)],
|
|
102
|
+
keyMaterial: { sdk: 'orchestration', deployment: client.deploymentConfig ?? null, destination: destinationId(client.destination), config, request: nextRequest }
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
responseClass: () => loadPeer('@sap-ai-sdk/orchestration', 'the response cache').OrchestrationResponse
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const foundationModels = {
|
|
110
|
+
sdk: 'foundation-models',
|
|
111
|
+
methods: {
|
|
112
|
+
run: { operation: 'chat', requestConfigIndex: 1 },
|
|
113
|
+
stream: { operation: 'stream', requestConfigIndex: 2 }
|
|
114
|
+
},
|
|
115
|
+
matches: (client) => typeof client?.run === 'function' && 'modelDeployment' in client,
|
|
116
|
+
|
|
117
|
+
/** @returns {CallInfo} */
|
|
118
|
+
extract (client, args) {
|
|
119
|
+
const request = args[0] ?? {}
|
|
120
|
+
const md = client.modelDeployment
|
|
121
|
+
const { messages, tools, ...params } = request
|
|
122
|
+
return {
|
|
123
|
+
model: typeof md === 'string' ? md : (md?.modelName ?? null),
|
|
124
|
+
deploymentId: typeof md === 'object' ? (md?.deploymentId ?? null) : null,
|
|
125
|
+
resourceGroup: typeof md === 'object' ? (md?.resourceGroup ?? null) : null,
|
|
126
|
+
messages: (messages ?? []).map(m => tag(m, 'request')),
|
|
127
|
+
tools,
|
|
128
|
+
params,
|
|
129
|
+
editable: { tools: true, params: true }
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
apply (client, args, info, result) {
|
|
134
|
+
const request = args[0] ?? {}
|
|
135
|
+
const { messages: _m, tools: _t, ...rest } = request
|
|
136
|
+
const nextRequest = { ...rest, ...result.params, messages: result.messages.map(untag) }
|
|
137
|
+
if (result.tools?.length || 'tools' in request) nextRequest.tools = result.tools
|
|
138
|
+
if (nextRequest.tools === undefined) delete nextRequest.tools
|
|
139
|
+
return {
|
|
140
|
+
client,
|
|
141
|
+
args: [nextRequest, ...args.slice(1)],
|
|
142
|
+
keyMaterial: { sdk: 'foundation-models', deployment: client.modelDeployment ?? null, destination: destinationId(client.destination), request: nextRequest }
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
responseClass: () => loadPeer('@sap-ai-sdk/foundation-models', 'the response cache').AzureOpenAiChatCompletionResponse
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const ADAPTERS = [orchestration, foundationModels]
|
|
150
|
+
|
|
151
|
+
/** @param {object} client */
|
|
152
|
+
function detectAdapter (client) {
|
|
153
|
+
return ADAPTERS.find(a => a.matches(client)) ?? null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A sibling of `client` built with `config`. Reused per (client, config) so repeated calls
|
|
158
|
+
* with the same optimized config don't construct new clients.
|
|
159
|
+
*/
|
|
160
|
+
function derivedClient (client, config) {
|
|
161
|
+
let byConfig = derivedClients.get(client)
|
|
162
|
+
if (!byConfig) { byConfig = new Map(); derivedClients.set(client, byConfig) }
|
|
163
|
+
const key = sha256(stableStringify(config))
|
|
164
|
+
let derived = byConfig.get(key)
|
|
165
|
+
if (!derived) {
|
|
166
|
+
const Ctor = client.constructor
|
|
167
|
+
derived = new Ctor(config, client.deploymentConfig, client.destination)
|
|
168
|
+
byConfig.set(key, derived)
|
|
169
|
+
if (byConfig.size > DERIVED_CLIENTS_PER_CLIENT) byConfig.delete(byConfig.keys().next().value)
|
|
170
|
+
}
|
|
171
|
+
return derived
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Identify a destination for the cache key without including credentials. */
|
|
175
|
+
function destinationId (destination) {
|
|
176
|
+
if (!destination || typeof destination !== 'object') return null
|
|
177
|
+
return destination.destinationName ?? destination.name ?? destination.url ?? null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const canDerive = (client) => typeof client?.constructor === 'function' && client.constructor !== Object
|
|
181
|
+
|
|
182
|
+
const isInlineConfig = (config) => !!config && typeof config === 'object' && !Array.isArray(config) && !!config.promptTemplating
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Return a copy of `args` whose requestConfig carries `timeout`, unless the caller set one.
|
|
186
|
+
* @param {any[]} args
|
|
187
|
+
* @param {number} index position of requestConfig
|
|
188
|
+
* @param {number|null} timeoutMs
|
|
189
|
+
*/
|
|
190
|
+
function withTimeout (args, index, timeoutMs) {
|
|
191
|
+
if (!(timeoutMs > 0)) return args
|
|
192
|
+
const current = args[index]
|
|
193
|
+
if (current?.timeout != null) return args
|
|
194
|
+
const next = [...args]
|
|
195
|
+
while (next.length < index) next.push(undefined)
|
|
196
|
+
next[index] = { ...(current ?? {}), timeout: timeoutMs }
|
|
197
|
+
return next
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The orchestration config may be an object, a module-fallback list, a JSON string or a
|
|
202
|
+
* stored-config reference. Return the first inline module config, if any.
|
|
203
|
+
*/
|
|
204
|
+
function firstModuleConfig (config) {
|
|
205
|
+
if (Array.isArray(config)) return config[0]
|
|
206
|
+
if (config && typeof config === 'object' && config.promptTemplating) return config
|
|
207
|
+
if (typeof config === 'string') {
|
|
208
|
+
try {
|
|
209
|
+
const parsed = JSON.parse(config)
|
|
210
|
+
const modules = parsed?.config?.modules ?? parsed?.modules
|
|
211
|
+
const first = Array.isArray(modules) ? modules[0] : modules
|
|
212
|
+
const pt = first?.prompt_templating
|
|
213
|
+
return pt ? { promptTemplating: { model: pt.model, prompt: pt.prompt } } : null
|
|
214
|
+
} catch { return null }
|
|
215
|
+
}
|
|
216
|
+
return null
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const CACHED_HEADERS = ['x-aicore-request-id']
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Plain-JSON form of an SDK response for the cache, or null if it has no data.
|
|
223
|
+
* @param {string} sdk
|
|
224
|
+
* @param {any} response
|
|
225
|
+
*/
|
|
226
|
+
function serializeResponse (sdk, response) {
|
|
227
|
+
const data = response?._data ?? response?.rawResponse?.data
|
|
228
|
+
if (!data || typeof data !== 'object') return null
|
|
229
|
+
const headers = {}
|
|
230
|
+
const raw = response.rawResponse?.headers ?? {}
|
|
231
|
+
for (const name of CACHED_HEADERS) if (raw[name] != null) headers[name] = String(raw[name])
|
|
232
|
+
return structuredClone({ sdk, data, headers })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const responseClasses = new Map()
|
|
236
|
+
|
|
237
|
+
/** Remember the response class of a live response, so cached entries can be rebuilt. */
|
|
238
|
+
function rememberResponseClass (sdk, response) {
|
|
239
|
+
if (response && typeof response.constructor === 'function' && response.constructor !== Object) {
|
|
240
|
+
responseClasses.set(sdk, response.constructor)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Rebuild an SDK response object from a cache entry. Each call gets its own copy.
|
|
246
|
+
* @param {{ sdk: string, data: object, headers: object }} entry
|
|
247
|
+
*/
|
|
248
|
+
function rehydrateResponse (entry) {
|
|
249
|
+
const adapter = ADAPTERS.find(a => a.sdk === entry.sdk)
|
|
250
|
+
const Cls = responseClasses.get(entry.sdk) ?? adapter.responseClass()
|
|
251
|
+
return new Cls({ data: structuredClone(entry.data), headers: { ...entry.headers }, status: 200 })
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
module.exports = {
|
|
255
|
+
orchestration,
|
|
256
|
+
foundationModels,
|
|
257
|
+
detectAdapter,
|
|
258
|
+
withTimeout,
|
|
259
|
+
firstModuleConfig,
|
|
260
|
+
serializeResponse,
|
|
261
|
+
rememberResponseClass,
|
|
262
|
+
rehydrateResponse
|
|
263
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Hooks on the @sap-ai-sdk client classes, for automatic instrumentation and for clients
|
|
4
|
+
* the app does not create itself (e.g. inside @sap-ai-sdk/langchain).
|
|
5
|
+
*
|
|
6
|
+
* The prototype methods of OrchestrationClient (chatCompletion, stream) and
|
|
7
|
+
* AzureOpenAiChatClient (run, stream) are replaced by a hook that:
|
|
8
|
+
* - routes the call through an optimizer when one is active for it: the optimizer of the
|
|
9
|
+
* current scope (wrapLangChain(), withUseCase()), else the one with autoInstrument enabled;
|
|
10
|
+
* - otherwise calls the original method unchanged.
|
|
11
|
+
* The original is kept on the hook (ORIGINAL), so the optimizer and explicitly wrapped
|
|
12
|
+
* clients call it directly and nothing is measured twice.
|
|
13
|
+
*
|
|
14
|
+
* Safety: hooks are only installed when needed, once per class, only for SDK major version 2,
|
|
15
|
+
* and failures to install are logged, never thrown. uninstall() restores the originals.
|
|
16
|
+
* Only the SDK copy resolved from the app root is hooked.
|
|
17
|
+
*/
|
|
18
|
+
const { AsyncLocalStorage } = require('node:async_hooks')
|
|
19
|
+
const { orchestration, foundationModels } = require('./adapters')
|
|
20
|
+
const { loadPeer, peerVersion } = require('../util/peer')
|
|
21
|
+
const log = require('../util/log')
|
|
22
|
+
|
|
23
|
+
const ORIGINAL = Symbol.for('cap-ai-token-optimizer.original')
|
|
24
|
+
const scopes = new AsyncLocalStorage()
|
|
25
|
+
const installed = [] // { proto, methodName, original }
|
|
26
|
+
let autoTarget = null // { optimizer, opts }
|
|
27
|
+
|
|
28
|
+
const TARGETS = [
|
|
29
|
+
{ pkg: '@sap-ai-sdk/orchestration', className: 'OrchestrationClient', adapter: orchestration },
|
|
30
|
+
{ pkg: '@sap-ai-sdk/foundation-models', className: 'AzureOpenAiChatClient', adapter: foundationModels }
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function hook (adapter, methodName, method, original) {
|
|
34
|
+
const hooked = function (...args) {
|
|
35
|
+
const scope = scopes.getStore()
|
|
36
|
+
const target = scope ?? autoTarget
|
|
37
|
+
if (!target) return original.apply(this, args)
|
|
38
|
+
return target.optimizer._call({ adapter, method, methodName, client: this, args, opts: target.opts ?? {} })
|
|
39
|
+
}
|
|
40
|
+
hooked[ORIGINAL] = original
|
|
41
|
+
return hooked
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Install the hooks (idempotent). Returns the names of the hooked classes.
|
|
46
|
+
* @param {{ load?: (pkg: string) => object, version?: (pkg: string) => string|null }} [deps]
|
|
47
|
+
*/
|
|
48
|
+
function install (deps = {}) {
|
|
49
|
+
const load = deps.load ?? ((pkg) => loadPeer(pkg))
|
|
50
|
+
const version = deps.version ?? peerVersion
|
|
51
|
+
const hooked = []
|
|
52
|
+
for (const t of TARGETS) {
|
|
53
|
+
let Cls
|
|
54
|
+
try {
|
|
55
|
+
Cls = load(t.pkg)?.[t.className]
|
|
56
|
+
} catch (e) {
|
|
57
|
+
if (e?.code !== 'PEER_DEPENDENCY_MISSING') log.warn(`could not load ${t.pkg} for instrumentation:`, e?.message)
|
|
58
|
+
continue
|
|
59
|
+
}
|
|
60
|
+
if (typeof Cls !== 'function') continue
|
|
61
|
+
const v = version(t.pkg)
|
|
62
|
+
if (v && !/^2\./.test(v)) {
|
|
63
|
+
log.warn(`${t.pkg}@${v} is not supported for instrumentation (supported: 2.x); its calls are not instrumented`)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
for (const [methodName, method] of Object.entries(t.adapter.methods)) {
|
|
67
|
+
const proto = Cls.prototype
|
|
68
|
+
const current = proto[methodName]
|
|
69
|
+
if (typeof current !== 'function' || current[ORIGINAL]) continue
|
|
70
|
+
try {
|
|
71
|
+
proto[methodName] = hook(t.adapter, methodName, method, current)
|
|
72
|
+
installed.push({ proto, methodName, original: current })
|
|
73
|
+
} catch (e) {
|
|
74
|
+
log.warn(`could not instrument ${t.className}.${methodName}:`, e?.message)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (Cls.prototype[Object.keys(t.adapter.methods)[0]]?.[ORIGINAL]) hooked.push(t.className)
|
|
78
|
+
}
|
|
79
|
+
return hooked
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Restore the original methods (mainly for tests). */
|
|
83
|
+
function uninstall () {
|
|
84
|
+
for (const { proto, methodName, original } of installed.splice(0)) proto[methodName] = original
|
|
85
|
+
autoTarget = null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Route all SDK calls without a scope through `optimizer`.
|
|
90
|
+
* @param {object} optimizer
|
|
91
|
+
* @param {{ useCase?: string }} [opts]
|
|
92
|
+
* @param {object} [deps] see install()
|
|
93
|
+
*/
|
|
94
|
+
function enableAuto (optimizer, opts = {}, deps) {
|
|
95
|
+
if (autoTarget && autoTarget.optimizer !== optimizer) log.warn('autoInstrument moved to another optimizer instance')
|
|
96
|
+
autoTarget = { optimizer, opts }
|
|
97
|
+
const hooked = install(deps)
|
|
98
|
+
if (hooked.length) log.info(`autoInstrument: ${hooked.join(', ')} calls are optimized and measured`)
|
|
99
|
+
else log.warn('autoInstrument: no supported @sap-ai-sdk client found to instrument')
|
|
100
|
+
return hooked
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Stop automatic routing for `optimizer` (hooks stay installed as pass-through). */
|
|
104
|
+
function disableAuto (optimizer) {
|
|
105
|
+
if (autoTarget?.optimizer === optimizer) autoTarget = null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run `fn` with SDK calls routed through `optimizer` with `opts`.
|
|
110
|
+
* @template T
|
|
111
|
+
* @param {object} optimizer
|
|
112
|
+
* @param {object} opts
|
|
113
|
+
* @param {() => T} fn
|
|
114
|
+
* @returns {T}
|
|
115
|
+
*/
|
|
116
|
+
function runInScope (optimizer, opts, fn) {
|
|
117
|
+
install()
|
|
118
|
+
return scopes.run({ optimizer, opts }, fn)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The original (unhooked) implementation of a possibly hooked method. */
|
|
122
|
+
const originalOf = (fn) => fn?.[ORIGINAL] ?? fn
|
|
123
|
+
|
|
124
|
+
module.exports = { install, uninstall, enableAuto, disableAuto, runInScope, originalOf, scopes, ORIGINAL }
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Configuration defaults and merge logic.
|
|
4
|
+
* Users configure the optimizer via `cds.requires.tokenOptimizer` (or programmatically).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const MODES = ['active', 'shadow', 'off']
|
|
8
|
+
|
|
9
|
+
/** @type {import('../index').TokenOptimizerConfig} */
|
|
10
|
+
const DEFAULTS = Object.freeze({
|
|
11
|
+
mode: 'active',
|
|
12
|
+
// Each step: false (off), true (defaults) or an options object. See lib/pipeline/steps/*.
|
|
13
|
+
steps: {
|
|
14
|
+
normalize: true,
|
|
15
|
+
dedupe: true,
|
|
16
|
+
history: { maxTurns: 10 },
|
|
17
|
+
tools: { maxTools: 15, maxDescriptionLength: 600, pinned: [] },
|
|
18
|
+
outputCap: { default: 1024 }
|
|
19
|
+
},
|
|
20
|
+
// Exact-match response cache. Only temperature <= maxTemperature is cached (null: any).
|
|
21
|
+
// scope: 'tenant' (default), 'user' or 'global'. `store` may be set in code (CacheStore).
|
|
22
|
+
cache: { enabled: true, ttlSeconds: 3600, maxEntries: 1000, maxTemperature: 0.2, scope: 'tenant' },
|
|
23
|
+
metrics: {
|
|
24
|
+
exporters: ['console'],
|
|
25
|
+
flushIntervalMs: 15000,
|
|
26
|
+
// Salt for the user-id hash in events. Set it from the environment, not in committed files.
|
|
27
|
+
userHashSalt: '',
|
|
28
|
+
// POST batches of events to a URL or a BTP destination (see lib/exporters/http.js for all options)
|
|
29
|
+
http: { url: '', destination: '', headers: {}, batchSize: 100, maxQueueSize: 10000, maxRetries: 3, timeoutMs: 10000 },
|
|
30
|
+
// OpenTelemetry metrics and spans through the providers registered by @cap-js/telemetry
|
|
31
|
+
otel: { enabled: false, tenantAttribute: true },
|
|
32
|
+
// TokenUsageLog rows in the app database (exporter `db`, alias `hana`)
|
|
33
|
+
db: { batchSize: 50, maxQueueSize: 10000, retentionDays: 30 }
|
|
34
|
+
},
|
|
35
|
+
// Price per 1k tokens by model name. Empty by default — prices vary per contract.
|
|
36
|
+
pricing: {},
|
|
37
|
+
// Per-use-case overrides, e.g. { "invoice-agent": { steps: { outputCap: { default: 256 } } } }
|
|
38
|
+
useCases: {},
|
|
39
|
+
// Request timeout applied when the caller does not set one (ms). null = SDK default.
|
|
40
|
+
timeoutMs: null,
|
|
41
|
+
// Opt-in retries performed by the wrapper (the SDK itself does not retry).
|
|
42
|
+
retry: {
|
|
43
|
+
enabled: false,
|
|
44
|
+
maxRetries: 2,
|
|
45
|
+
baseDelayMs: 500,
|
|
46
|
+
maxDelayMs: 10000,
|
|
47
|
+
on: ['RATE_LIMIT', 'SERVER', 'NETWORK', 'TIMEOUT']
|
|
48
|
+
},
|
|
49
|
+
// promptCache: warn when the system prompt or tools sent for a use case and model change
|
|
50
|
+
// between calls, which defeats the provider's prompt caching
|
|
51
|
+
advisories: { promptCache: true },
|
|
52
|
+
// Hook the @sap-ai-sdk client classes so existing code needs no changes. `true` or
|
|
53
|
+
// { enabled, useCase }; useCase labels calls made outside withUseCase()/wrap().
|
|
54
|
+
autoInstrument: { enabled: false, useCase: 'default' },
|
|
55
|
+
// Read-only admin service at /odata/v4/token-optimizer, for users with `role`
|
|
56
|
+
admin: { enabled: false, role: 'TokenOptimizerAdmin' },
|
|
57
|
+
captureContent: false
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// Only object literals merge; class instances (e.g. a custom cache store) are kept as they are.
|
|
61
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && [Object.prototype, null].includes(Object.getPrototypeOf(v))
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Deep merge. Objects merge recursively; arrays and scalars from `override` replace.
|
|
65
|
+
* `undefined` in override is ignored. Neither input is mutated.
|
|
66
|
+
* @template T
|
|
67
|
+
* @param {T} base
|
|
68
|
+
* @param {any} override
|
|
69
|
+
* @returns {T}
|
|
70
|
+
*/
|
|
71
|
+
function deepMerge (base, override) {
|
|
72
|
+
if (!isPlainObject(override)) return override === undefined ? clone(base) : clone(override)
|
|
73
|
+
const out = isPlainObject(base) ? clone(base) : {}
|
|
74
|
+
for (const [k, v] of Object.entries(override)) {
|
|
75
|
+
if (v === undefined) continue
|
|
76
|
+
out[k] = isPlainObject(v) && isPlainObject(out[k]) ? deepMerge(out[k], v) : clone(v)
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function clone (v) {
|
|
82
|
+
if (Array.isArray(v)) return v.map(clone)
|
|
83
|
+
if (isPlainObject(v)) return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clone(x)]))
|
|
84
|
+
return v
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Builds the effective configuration from user input on top of the defaults.
|
|
89
|
+
* Accepts `true` (use defaults) or an object. Invalid values fall back to defaults with a warning.
|
|
90
|
+
* @param {object|boolean|undefined} userConfig
|
|
91
|
+
* @param {{ warn?: (msg: string) => void }} [log]
|
|
92
|
+
*/
|
|
93
|
+
function resolveConfig (userConfig, log = {}) {
|
|
94
|
+
const input = isPlainObject(userConfig) ? stripCdsKeys(userConfig) : {}
|
|
95
|
+
const cfg = deepMerge(DEFAULTS, input)
|
|
96
|
+
if (!MODES.includes(cfg.mode)) {
|
|
97
|
+
log.warn?.(`Invalid tokenOptimizer.mode "${cfg.mode}", using "active". Allowed: ${MODES.join(', ')}`)
|
|
98
|
+
cfg.mode = 'active'
|
|
99
|
+
}
|
|
100
|
+
for (const [name, uc] of Object.entries(cfg.useCases ?? {})) {
|
|
101
|
+
if (uc?.mode !== undefined && !MODES.includes(uc.mode)) {
|
|
102
|
+
log.warn?.(`Invalid tokenOptimizer.useCases.${name}.mode "${uc.mode}", using "${cfg.mode}"`)
|
|
103
|
+
delete uc.mode
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (typeof cfg.autoInstrument === 'boolean') cfg.autoInstrument = { ...DEFAULTS.autoInstrument, enabled: cfg.autoInstrument }
|
|
107
|
+
if (typeof cfg.metrics.exporters === 'string') cfg.metrics.exporters = [cfg.metrics.exporters]
|
|
108
|
+
if (!Array.isArray(cfg.metrics.exporters)) cfg.metrics.exporters = [...DEFAULTS.metrics.exporters]
|
|
109
|
+
return cfg
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Remove CAP-internal keys (kind, impl, model, …) that cds adds to `requires` entries. */
|
|
113
|
+
function stripCdsKeys (obj) {
|
|
114
|
+
const { kind, impl, model, ...rest } = obj
|
|
115
|
+
return rest
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Effective config for a use case: global config with `useCases[useCase]` merged on top.
|
|
120
|
+
* @param {object} cfg resolved config
|
|
121
|
+
* @param {string} [useCase]
|
|
122
|
+
*/
|
|
123
|
+
function forUseCase (cfg, useCase) {
|
|
124
|
+
const override = useCase && cfg.useCases?.[useCase]
|
|
125
|
+
return override ? deepMerge(cfg, override) : cfg
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = { DEFAULTS, MODES, resolveConfig, deepMerge, forUseCase }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Console exporter: writes one JSON line per event to stdout (or a given stream).
|
|
4
|
+
* @param {{ stream?: { write: (s: string) => any } }} [opts]
|
|
5
|
+
* @returns {import('../../index').Exporter}
|
|
6
|
+
*/
|
|
7
|
+
function createConsoleExporter ({ stream = process.stdout } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
name: 'console',
|
|
10
|
+
export (event) {
|
|
11
|
+
stream.write(`${JSON.stringify({ type: 'ai-core-call', ...event })}\n`)
|
|
12
|
+
},
|
|
13
|
+
flush () {},
|
|
14
|
+
shutdown () {}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
module.exports = { createConsoleExporter }
|