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