@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/SECURITY.md ADDED
@@ -0,0 +1,39 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Security fixes are made for the latest minor release. Before 1.0.0, that is the latest
6
+ 0.x release.
7
+
8
+ | Version | Supported |
9
+ |---|---|
10
+ | latest 0.x | ✅ |
11
+ | older | ❌ |
12
+
13
+ ## Reporting a vulnerability
14
+
15
+ Please do **not** open a public issue for security problems.
16
+
17
+ Report them privately through GitHub:
18
+ [Security → Report a vulnerability](https://github.com/viveka10/npm_token_optimizer/security/advisories/new).
19
+ Include the package version, a description, and steps to reproduce if possible.
20
+
21
+ You can expect an acknowledgement within 7 days. Fixes are released as patch versions and
22
+ noted in the [CHANGELOG](CHANGELOG.md).
23
+
24
+ ## How this package handles data
25
+
26
+ - **Prompt and completion text** are never logged, exported or stored by default. Events
27
+ contain a SHA-256 hash and the length. Raw text only appears in events with
28
+ `captureContent: true`, and is never written to the database.
29
+ - **User ids** are recorded as a salted hash (`metrics.userHashSalt`; set it from the
30
+ environment, not in committed files).
31
+ - **Credentials** are never read or stored by this package. AI Core credentials are handled
32
+ by the SAP Cloud SDK for AI; destination credentials by the SAP Cloud SDK. Don't put
33
+ secrets in `metrics.http.headers` in committed files.
34
+ - **The response cache** is in memory per app instance. By default it is scoped per tenant,
35
+ so cached answers are never shared across tenants (`cache.scope`).
36
+ - **The admin service** is read-only and requires a role (`admin.role`).
37
+ - **Published package contents** are checked before every publish (`npm run check:pack`):
38
+ no tests, `.env`, `.cdsrc-private.json`, service keys, certificates or benchmark results.
39
+ Releases are published from GitHub Actions with npm provenance.
package/cds-plugin.js ADDED
@@ -0,0 +1,50 @@
1
+ 'use strict'
2
+ /**
3
+ * CAP plugin, loaded automatically by @sap/cds when this package is a dependency.
4
+ * Reads cds.requires.tokenOptimizer, starts the exporters and flushes them on shutdown.
5
+ */
6
+ const cds = globalThis.cds ?? require('@sap/cds')
7
+ const { optimizer, countText } = require('./index')
8
+ const log = require('./lib/util/log')
9
+ const path = require('node:path')
10
+
11
+ try {
12
+ optimizer.configure(cds.env.requires?.tokenOptimizer)
13
+ log.debug('configured, mode =', optimizer.config.mode, 'exporters =', optimizer.config.metrics.exporters)
14
+ setupModels(optimizer.config)
15
+ } catch (e) {
16
+ log.error('could not configure token optimizer:', e?.message)
17
+ }
18
+
19
+ /**
20
+ * Add our CDS models to the app's model when the features using them are enabled:
21
+ * the TokenUsageLog entity for the db exporter, the read-only admin service, and the
22
+ * UsageLog projection when both are on. Plugins load before the model, so adding a `model`
23
+ * to our cds.requires entry is enough.
24
+ */
25
+ function setupModels (cfg) {
26
+ const exporters = cfg.metrics.exporters ?? []
27
+ const dbLog = exporters.includes('db') || exporters.includes('hana')
28
+ const admin = cfg.admin?.enabled
29
+ const models = []
30
+ if (dbLog) models.push('db/token-usage-log')
31
+ if (admin) models.push('srv/admin-service')
32
+ if (admin && dbLog) models.push('srv/admin-usage-log')
33
+ if (!models.length) return
34
+
35
+ const req = cds.env.requires.tokenOptimizer ??= {}
36
+ req.model = [...new Set([].concat(req.model ?? [], models.map(m => path.join(__dirname, m))))]
37
+ if (!admin) return
38
+ const role = cfg.admin.role || 'TokenOptimizerAdmin'
39
+ cds.on('loaded', (csn) => {
40
+ const srv = csn?.definitions?.TokenOptimizerService
41
+ if (srv) srv['@requires'] = role
42
+ })
43
+ }
44
+
45
+ // Load the tokenizer tables once the server is up, so the first AI call doesn't pay for it.
46
+ cds.once('served', () => setImmediate(() => {
47
+ try { countText('warm up', 'gpt-4o') } catch { /* estimation falls back to the heuristic */ }
48
+ }))
49
+
50
+ cds.on('shutdown', () => optimizer.shutdown())
@@ -0,0 +1,44 @@
1
+ namespace cap.ai.token_optimizer;
2
+
3
+ /**
4
+ * One row per AI Core call, written by the `db` metrics exporter (alias `hana`).
5
+ * Contains metrics only: never prompt or completion text. In multitenant apps each tenant's
6
+ * calls are written to that tenant's database container.
7
+ */
8
+ entity TokenUsageLog {
9
+ key ID : UUID;
10
+ timestamp : Timestamp;
11
+ tenant : String(255);
12
+ userHash : String(16);
13
+ cdsService : String(255);
14
+ useCase : String(255);
15
+ mode : String(10);
16
+ model : String(255);
17
+ deploymentId : String(255);
18
+ resourceGroup : String(255);
19
+ sdk : String(30);
20
+ operation : String(10);
21
+ requestId : String(255);
22
+ finishReason : String(50);
23
+ promptEstimatedBefore : Integer;
24
+ promptEstimatedAfter : Integer;
25
+ promptTokens : Integer;
26
+ completionTokens : Integer;
27
+ totalTokens : Integer;
28
+ cachedTokens : Integer;
29
+ savedTokensEstimate : Integer;
30
+ /** JSON object: estimated tokens saved per step */
31
+ savingsByStep : LargeString;
32
+ cacheHit : Boolean;
33
+ promptPrefixChanged : Boolean;
34
+ costEstimate : Decimal(20, 6);
35
+ latencyMs : Integer;
36
+ retries : Integer;
37
+ status : String(10);
38
+ errorCategory : String(30);
39
+ errorHttpStatus : Integer;
40
+ errorCode : String(100);
41
+ errorMessage : String(300);
42
+ promptHash : String(64);
43
+ promptLength : Integer;
44
+ }
@@ -0,0 +1,140 @@
1
+ # Add @viveka10/cap-ai-token-optimizer to an existing CAP app in 5 minutes
2
+
3
+ This guide adds token measurement to a CAP Node.js app that already calls SAP AI Core
4
+ through the SAP Cloud SDK for AI. The first steps change no code and no prompts. You start
5
+ in shadow mode, look at the numbers, and switch to optimization when you're ready.
6
+
7
+ ## 1. Check the prerequisites (30 seconds)
8
+
9
+ ```sh
10
+ node --version # v22.12 or later
11
+ npm ls @sap/cds @sap-ai-sdk/orchestration @sap-ai-sdk/foundation-models
12
+ ```
13
+
14
+ You need `@sap/cds` 9 or 10, and `@sap-ai-sdk/orchestration` and/or
15
+ `@sap-ai-sdk/foundation-models` 2.8 or later. Each `@sap-ai-sdk` package should appear once in
16
+ the tree (see [Limits](#limits-of-automatic-instrumentation)).
17
+
18
+ ## 2. Install (1 minute)
19
+
20
+ ```sh
21
+ npm add @viveka10/cap-ai-token-optimizer
22
+ ```
23
+
24
+ The package is a CAP plugin, so there's nothing to register.
25
+
26
+ ## 3. Turn on measurement without code changes (1 minute)
27
+
28
+ Add to `package.json` (or `.cdsrc.json`):
29
+
30
+ ```json
31
+ {
32
+ "cds": {
33
+ "requires": {
34
+ "tokenOptimizer": {
35
+ "mode": "shadow",
36
+ "autoInstrument": true
37
+ }
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ - `autoInstrument` hooks the SDK's `OrchestrationClient` and `AzureOpenAiChatClient`, so every
44
+ call your app already makes is measured.
45
+ - `shadow` runs the optimizer but sends your original prompts: you see what would be saved,
46
+ and nothing about your AI calls changes.
47
+
48
+ ## 4. Run and look (1 minute)
49
+
50
+ ```sh
51
+ cds watch
52
+ ```
53
+
54
+ Use the feature of your app that calls AI Core. Each call prints one line like:
55
+
56
+ ```json
57
+ {"type":"ai-core-call","useCase":"default","mode":"shadow","model":"gpt-4.1",
58
+ "tokens":{"promptEstimatedBefore":2015,"promptEstimatedAfter":805,"prompt":2010,"completion":41,...},
59
+ "savings":{"estimatedTokens":1210,"byStep":{"history":774,"dedupe":340,"normalize":96,...},...},
60
+ "status":"success","latencyMs":640,...}
61
+ ```
62
+
63
+ `savings.estimatedTokens` is what active mode would have saved on this call;
64
+ `savings.byStep` shows which step saved it.
65
+
66
+ For a summary, enable the admin service in the same configuration block
67
+ (`"admin": { "enabled": true }`), give your user the `TokenOptimizerAdmin` role, and open
68
+ `/odata/v4/token-optimizer/summary()`.
69
+
70
+ ## 5. Name your use cases (1 minute, optional)
71
+
72
+ Calls found by `autoInstrument` are grouped under the use case `default`. Name them where
73
+ your handlers call AI Core, without touching the client code:
74
+
75
+ ```js
76
+ const { optimizer } = require('@viveka10/cap-ai-token-optimizer')
77
+
78
+ this.on('summarizeTicket', req =>
79
+ optimizer.withUseCase('ticket-summary', () => summarize(req.data.ID)))
80
+ ```
81
+
82
+ For LangChain models from `@sap-ai-sdk/langchain`, wrap the model once:
83
+
84
+ ```js
85
+ const { OrchestrationClient } = require('@sap-ai-sdk/langchain')
86
+ const model = optimizer.wrapLangChain(new OrchestrationClient(config), { useCase: 'support-agent' })
87
+ ```
88
+
89
+ For clients you create yourself, you can also use `optimizer.wrap(client, { useCase })`.
90
+
91
+ ## 6. Switch on optimization (30 seconds)
92
+
93
+ When the savings and the answers look right, switch the mode, for one use case or for all:
94
+
95
+ ```json
96
+ "tokenOptimizer": {
97
+ "mode": "shadow",
98
+ "autoInstrument": true,
99
+ "useCases": {
100
+ "ticket-summary": { "mode": "active" }
101
+ }
102
+ }
103
+ ```
104
+
105
+ In `active` mode the optimized prompt is sent and identical low-temperature calls are
106
+ answered from the cache. Compare answer quality before and after with
107
+ `npm run bench -- --live` in the package, or by comparing `shadow` and `active` events
108
+ for the same requests.
109
+
110
+ ## Before going to production
111
+
112
+ - **`pricing`:** set prices per model from your contract to get `costEstimate`.
113
+ - **`metrics.userHashSalt`:** set it from the environment
114
+ (`cds_requires_tokenOptimizer_metrics_userHashSalt`), not in committed files.
115
+ - **Reporting:** add `otel` (with `@cap-js/telemetry`, for SAP Cloud ALM or Dynatrace), `db`
116
+ (usage log table) or `http` to `metrics.exporters`.
117
+ - **`captureContent`:** keep it off.
118
+ - **Admin role:** add the `TokenOptimizerAdmin` role to `xs-security.json` if you enabled the
119
+ admin service.
120
+
121
+ ## Limits of automatic instrumentation
122
+
123
+ - **Clients:** only `OrchestrationClient` (`chatCompletion`, `stream`) and
124
+ `AzureOpenAiChatClient` (`run`, `stream`) from `@sap-ai-sdk` 2.x are hooked. This includes the
125
+ ones that `@sap-ai-sdk/langchain` creates internally. Embedding clients and direct HTTP calls
126
+ to AI Core are not measured.
127
+ - **SDK copy:** the hooks are installed on the copy of each SDK package resolved from your
128
+ app's root. If `npm ls @sap-ai-sdk/orchestration` shows a second, nested copy, calls through
129
+ that copy are not measured; run `npm dedupe` or align the versions.
130
+ - **Scope:** the hooks apply to every caller in the process, including libraries. If you want
131
+ to choose exactly which calls are optimized, leave `autoInstrument` off and use `wrap()`,
132
+ `withUseCase()` and `wrapLangChain()` instead.
133
+ - **Streaming:** `stream()` calls are measured but not optimized, and have no token usage.
134
+ - **Use cases:** without `withUseCase()`, all hooked calls share the use case set in
135
+ `autoInstrument.useCase` (default `default`), so per-use-case settings apply to all of them.
136
+ - **Start-up:** the hooks are installed when the CAP plugin loads. Outside CAP, call
137
+ `optimizer.configure({ autoInstrument: true })`.
138
+ - **Turning it off:** `"autoInstrument": false` or
139
+ `cds_requires_tokenOptimizer_autoInstrument=false`. With it off, the hooks call the SDK's
140
+ original methods unchanged.
package/index.d.ts ADDED
@@ -0,0 +1,291 @@
1
+ // Type definitions for @viveka10/cap-ai-token-optimizer
2
+
3
+ export type Mode = 'active' | 'shadow' | 'off'
4
+
5
+ export type FailureCategory =
6
+ | 'RATE_LIMIT' | 'CONTENT_FILTER' | 'CONTEXT_LENGTH' | 'TIMEOUT' | 'AUTH'
7
+ | 'DEPLOYMENT_NOT_FOUND' | 'SERVER' | 'NETWORK' | 'UNKNOWN'
8
+
9
+ export interface ModelPrice {
10
+ inputPer1k?: number
11
+ outputPer1k?: number
12
+ /** Price for cached prompt tokens; defaults to inputPer1k. */
13
+ cachedInputPer1k?: number
14
+ }
15
+
16
+ type Message = Record<string, unknown>
17
+ type Tool = Record<string, unknown>
18
+ /** A step is off (false), on with defaults (true), or on with options ({ enabled?: false } turns it off). */
19
+ type Step<T> = boolean | (T & { enabled?: boolean })
20
+
21
+ export interface StepsConfig {
22
+ normalize?: Step<{ minifyJson?: boolean, minifyJsonFences?: boolean, collapseSpaces?: boolean }>
23
+ dedupe?: Step<{ messages?: boolean, paragraphs?: boolean, minParagraphLength?: number }>
24
+ history?: Step<{
25
+ maxTurns?: number
26
+ /** 'sliding-window' (default) or a function returning the messages to keep. */
27
+ strategy?: 'sliding-window' | ((input: { messages: Message[], maxTurns: number, model: string | null }) => Message[])
28
+ /** Called with the removed messages; the returned message is inserted after the system messages. */
29
+ summarizer?: ((input: { dropped: Message[], kept: Message[], model: string | null }) => Promise<Message | null> | Message | null) | null
30
+ }>
31
+ tools?: Step<{
32
+ maxTools?: number
33
+ maxDescriptionLength?: number
34
+ /** Tool names that are never removed. */
35
+ pinned?: string[]
36
+ selector?: ((input: { tools: Tool[], messages: Message[], maxTools: number, keep: Set<string> }) => Tool[]) | null
37
+ }>
38
+ outputCap?: Step<{ default?: number, reasoningModels?: boolean }>
39
+ }
40
+
41
+ /** Response cache store. Methods may be sync or async. Values are plain JSON. */
42
+ export interface CacheStore {
43
+ get(key: string): unknown | Promise<unknown>
44
+ set(key: string, value: unknown, ttlMs: number): void | Promise<void>
45
+ delete?(key: string): void | Promise<void>
46
+ clear?(): void | Promise<void>
47
+ }
48
+
49
+ export declare class MemoryCacheStore implements CacheStore {
50
+ constructor(opts?: { maxEntries?: number, now?: () => number })
51
+ get(key: string): unknown
52
+ set(key: string, value: unknown, ttlMs: number): void
53
+ delete(key: string): void
54
+ clear(): void
55
+ readonly size: number
56
+ }
57
+
58
+ export interface TokenOptimizerConfig {
59
+ mode?: Mode
60
+ steps?: StepsConfig
61
+ cache?: {
62
+ enabled?: boolean
63
+ ttlSeconds?: number
64
+ maxEntries?: number
65
+ /** Only calls with temperature <= this are cached. null caches regardless of temperature. Default 0.2. */
66
+ maxTemperature?: number | null
67
+ /** Who may share cached answers. Default 'tenant'. */
68
+ scope?: 'tenant' | 'user' | 'global'
69
+ /** Custom store (e.g. Redis), set in code. Default: in-memory LRU. */
70
+ store?: CacheStore
71
+ }
72
+ metrics?: {
73
+ /** Built-in exporter names ('console') or custom exporter objects. */
74
+ exporters?: Array<string | Exporter> | string
75
+ flushIntervalMs?: number
76
+ /** Salt for the user-id hash. Set it from the environment, not in committed files. */
77
+ userHashSalt?: string
78
+ http?: {
79
+ /** Endpoint URL. Use either url or destination. */
80
+ url?: string
81
+ /** BTP destination name (Internet destinations), resolved with @sap-cloud-sdk/connectivity. */
82
+ destination?: string
83
+ headers?: Record<string, string>
84
+ batchSize?: number
85
+ /** When full, the oldest events are dropped (and counted). */
86
+ maxQueueSize?: number
87
+ maxRetries?: number
88
+ baseDelayMs?: number
89
+ maxDelayMs?: number
90
+ timeoutMs?: number
91
+ destinationCacheMs?: number
92
+ }
93
+ /** TokenUsageLog rows in the app database (exporter `db`, alias `hana`). */
94
+ db?: { batchSize?: number, maxQueueSize?: number, /** 0 keeps all rows. Default 30. */ retentionDays?: number }
95
+ otel?: {
96
+ /** Shortcut for adding 'otel' to exporters. */
97
+ enabled?: boolean
98
+ /** Add sap.tenancy.tenant_id to metrics and spans. Default true. */
99
+ tenantAttribute?: boolean
100
+ }
101
+ }
102
+ /** Price per 1k tokens by model name. */
103
+ pricing?: Record<string, ModelPrice>
104
+ /** Per-use-case overrides, merged over the global config. */
105
+ useCases?: Record<string, Omit<TokenOptimizerConfig, 'useCases'>>
106
+ /** Default request timeout (ms) when the caller sets none. null = SDK default. */
107
+ timeoutMs?: number | null
108
+ retry?: {
109
+ enabled?: boolean
110
+ maxRetries?: number
111
+ baseDelayMs?: number
112
+ maxDelayMs?: number
113
+ on?: FailureCategory[]
114
+ }
115
+ /**
116
+ * Hook the @sap-ai-sdk client classes so existing code needs no changes.
117
+ * `useCase` labels calls made outside wrap(), withUseCase() and wrapLangChain(). Default off.
118
+ */
119
+ autoInstrument?: boolean | { enabled?: boolean, useCase?: string }
120
+ /** promptCache: flag calls whose system prompt or tools changed for the same use case and model. Default true. */
121
+ advisories?: { promptCache?: boolean }
122
+ /** Read-only OData service at /odata/v4/token-optimizer. */
123
+ admin?: { enabled?: boolean, role?: string }
124
+ /** Include raw prompt and completion text in events. Off by default. */
125
+ captureContent?: boolean
126
+ }
127
+
128
+ export interface CallEvent {
129
+ schemaVersion: number
130
+ id: string
131
+ timestamp: string
132
+ tenant: string | null
133
+ /** Hashed user id. */
134
+ user: string | null
135
+ cdsService: string | null
136
+ useCase: string
137
+ mode: Mode
138
+ model: string | null
139
+ deploymentId: string | null
140
+ resourceGroup: string | null
141
+ sdk: 'orchestration' | 'foundation-models' | null
142
+ operation: 'chat' | 'stream'
143
+ requestId: string | null
144
+ finishReason: string | null
145
+ estimateMethod: 'tiktoken' | 'heuristic' | null
146
+ tokens: {
147
+ promptEstimatedBefore: number | null
148
+ promptEstimatedAfter: number | null
149
+ prompt: number | null
150
+ completion: number | null
151
+ total: number | null
152
+ cached: number | null
153
+ }
154
+ /** In shadow mode all savings are what active mode would have saved. */
155
+ savings: {
156
+ estimatedTokens: number
157
+ /** Estimated prompt tokens saved per step; `cache` holds the tokens of a cached call. */
158
+ byStep: Record<string, number>
159
+ cacheHit: boolean
160
+ /** Short step notes (counts only, never content). */
161
+ notes: string[]
162
+ /** The pipeline failed and the original request was sent. */
163
+ failed: boolean
164
+ }
165
+ costEstimate: number | null
166
+ latencyMs: number | null
167
+ retries: number
168
+ status: 'success' | 'error'
169
+ error: { category: FailureCategory, httpStatus: number | null, code: string | null, message: string } | null
170
+ promptHash: string | null
171
+ promptLength: number | null
172
+ /** Prompt-caching advisory; null when not checked (disabled, cache hit). */
173
+ advisory: { promptPrefixChanged: boolean, changed: Array<'system' | 'tools'>, causedByOptimizer: boolean } | null
174
+ /** Only present when captureContent is true. */
175
+ content?: { messages: unknown[], completion: string | null }
176
+ }
177
+
178
+ export interface Advisory {
179
+ type: 'PROMPT_PREFIX_CHANGED'
180
+ useCase: string
181
+ model: string | null
182
+ promptPrefixChanged: true
183
+ changed: Array<'system' | 'tools'>
184
+ /** The caller sent the same prefix as before; an optimizer step (e.g. tools) changed it. */
185
+ causedByOptimizer: boolean
186
+ }
187
+
188
+ export interface Exporter {
189
+ name?: string
190
+ export(event: CallEvent): void | Promise<void>
191
+ flush?(): void | Promise<void>
192
+ shutdown?(): void | Promise<void>
193
+ stats?(): Record<string, number>
194
+ }
195
+
196
+ export interface Totals {
197
+ calls: number
198
+ success: number
199
+ errors: number
200
+ cacheHits: number
201
+ tokens: { prompt: number, completion: number, total: number, cached: number }
202
+ savedTokensEstimate: number
203
+ costEstimate: number
204
+ }
205
+
206
+ export interface Stats extends Totals {
207
+ since: string
208
+ errorsByCategory: Partial<Record<FailureCategory, number>>
209
+ savingsByStep: Record<string, number>
210
+ latencyMs: { count: number, avg: number | null, p50: number | null, p95: number | null, max: number | null }
211
+ byUseCase: Record<string, Totals>
212
+ byModel: Record<string, Totals>
213
+ /** Calls whose system prompt or tools changed, per use case (prompt-caching advisory). */
214
+ promptPrefixChanges: Record<string, number>
215
+ listenerErrors: number
216
+ exporterErrors: Record<string, number>
217
+ /** From exporters with a stats() method, e.g. http: { queued, sent, dropped, failedAttempts, rejectedBatches }. */
218
+ exporters: Record<string, Record<string, number>>
219
+ }
220
+
221
+ export interface WrapOptions {
222
+ useCase?: string
223
+ /** CDS service name; defaults to the request's base path from cds.context. */
224
+ cdsService?: string
225
+ }
226
+
227
+ export interface ChatOptions extends WrapOptions {
228
+ /** Default 'orchestration'. */
229
+ sdk?: 'orchestration' | 'foundation-models'
230
+ model?: string
231
+ messages: Message[]
232
+ tools?: Tool[]
233
+ maxTokens?: number
234
+ temperature?: number
235
+ /** Other model parameters. */
236
+ params?: Record<string, unknown>
237
+ deploymentId?: string
238
+ resourceGroup?: string
239
+ /** Passed to the SDK as requestConfig (e.g. { timeout }). */
240
+ requestConfig?: Record<string, unknown>
241
+ }
242
+
243
+ export interface ChatResult {
244
+ content: string | null
245
+ toolCalls: unknown[] | null
246
+ finishReason: string | null
247
+ usage: Record<string, unknown> | null
248
+ /** The original SDK response object. */
249
+ response: unknown
250
+ }
251
+
252
+ export declare class TokenOptimizer {
253
+ constructor(config?: TokenOptimizerConfig | boolean, deps?: Record<string, unknown>)
254
+ readonly config: Required<TokenOptimizerConfig>
255
+ readonly collector: { readonly exporters: Exporter[] }
256
+ configure(config?: TokenOptimizerConfig | boolean): this
257
+ wrap<T extends object>(client: T, opts?: WrapOptions): T
258
+ chat(opts: ChatOptions): Promise<ChatResult>
259
+ /** Run fn so that @sap-ai-sdk calls inside it (any client, also ones created by libraries) are optimized and measured with useCase. */
260
+ withUseCase<R>(useCase: string, fn: () => R, opts?: { cdsService?: string }): R
261
+ /** Instrument a LangChain chat model from @sap-ai-sdk/langchain. Returns the same model. */
262
+ wrapLangChain<T extends object>(model: T, opts?: WrapOptions): T
263
+ stats(): Stats
264
+ resetStats(): void
265
+ on(type: 'call', listener: (event: CallEvent) => void): this
266
+ on(type: 'advisory', listener: (advisory: Advisory) => void): this
267
+ off(type: 'call' | 'advisory', listener: (arg: any) => void): this
268
+ once(type: 'call', listener: (event: CallEvent) => void): this
269
+ once(type: 'advisory', listener: (advisory: Advisory) => void): this
270
+ flush(): Promise<void>
271
+ shutdown(): Promise<void>
272
+ }
273
+
274
+ export interface ClassifiedError {
275
+ category: FailureCategory
276
+ httpStatus: number | null
277
+ code: string | null
278
+ message: string
279
+ retryAfterMs: number | null
280
+ }
281
+
282
+ /** Process-wide optimizer, configured from cds.requires.tokenOptimizer. */
283
+ export declare const optimizer: TokenOptimizer
284
+ export declare function createOptimizer(config?: TokenOptimizerConfig, deps?: Record<string, unknown>): TokenOptimizer
285
+ export declare function resolveConfig(config?: TokenOptimizerConfig | boolean, log?: { warn?: (msg: string) => void }): Required<TokenOptimizerConfig>
286
+ export declare const DEFAULTS: Readonly<Required<TokenOptimizerConfig>>
287
+ export declare function classifyError(err: unknown, opts?: { now?: number }): ClassifiedError
288
+ export declare const CATEGORIES: readonly FailureCategory[]
289
+ export declare function estimateMessages(messages: unknown[], opts?: { model?: string, tools?: unknown[] }): { tokens: number, method: 'tiktoken' | 'heuristic' }
290
+ export declare function countText(text: string, model?: string): { tokens: number, method: 'tiktoken' | 'heuristic' }
291
+ export declare function createConsoleExporter(opts?: { stream?: { write(s: string): unknown } }): Exporter
package/index.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict'
2
+ const { TokenOptimizer } = require('./lib/optimizer')
3
+ const { resolveConfig, DEFAULTS } = require('./lib/config')
4
+ const { classifyError, CATEGORIES } = require('./lib/metrics/classifier')
5
+ const { estimateMessages, countText } = require('./lib/tokens/estimator')
6
+ const { createConsoleExporter } = require('./lib/exporters/console')
7
+ const { MemoryCacheStore } = require('./lib/cache')
8
+
9
+ // One optimizer per process, even if two copies of this package get loaded
10
+ // (e.g. a `file:` install next to a registry install).
11
+ const KEY = Symbol.for('cap-ai-token-optimizer.singleton')
12
+ globalThis[KEY] ??= new TokenOptimizer()
13
+
14
+ /** @type {TokenOptimizer} */
15
+ const optimizer = globalThis[KEY]
16
+
17
+ /**
18
+ * Create a separate optimizer instance with its own config, stats and exporters.
19
+ * @param {object} [config]
20
+ * @param {object} [deps]
21
+ */
22
+ const createOptimizer = (config = {}, deps) => new TokenOptimizer(config, deps)
23
+
24
+ module.exports = {
25
+ optimizer,
26
+ createOptimizer,
27
+ TokenOptimizer,
28
+ resolveConfig,
29
+ DEFAULTS,
30
+ classifyError,
31
+ CATEGORIES,
32
+ estimateMessages,
33
+ countText,
34
+ createConsoleExporter,
35
+ MemoryCacheStore
36
+ }
@@ -0,0 +1,67 @@
1
+ 'use strict'
2
+ /**
3
+ * Exact-match response cache.
4
+ *
5
+ * CacheStore interface (sync or async; plug in Redis etc. via `cache.store` in code):
6
+ * get(key) → value | undefined
7
+ * set(key, value, ttlMs)
8
+ * delete(key), clear()
9
+ * Values are plain JSON ({ sdk, data, headers }), so any store that can hold JSON works.
10
+ */
11
+ const { sha256, stableStringify } = require('../util/hash')
12
+
13
+ class MemoryCacheStore {
14
+ /** @param {{ maxEntries?: number, now?: () => number }} [opts] */
15
+ constructor ({ maxEntries = 1000, now = Date.now } = {}) {
16
+ this.maxEntries = maxEntries
17
+ this._now = now
18
+ this._map = new Map()
19
+ }
20
+
21
+ get (key) {
22
+ const entry = this._map.get(key)
23
+ if (!entry) return undefined
24
+ if (entry.expires <= this._now()) { this._map.delete(key); return undefined }
25
+ // refresh LRU position
26
+ this._map.delete(key)
27
+ this._map.set(key, entry)
28
+ return entry.value
29
+ }
30
+
31
+ set (key, value, ttlMs) {
32
+ if (!(this.maxEntries > 0) || !(ttlMs > 0)) return
33
+ this._map.delete(key)
34
+ this._map.set(key, { value, expires: this._now() + ttlMs })
35
+ while (this._map.size > this.maxEntries) this._map.delete(this._map.keys().next().value)
36
+ }
37
+
38
+ delete (key) { this._map.delete(key) }
39
+ clear () { this._map.clear() }
40
+ get size () { return this._map.size }
41
+ }
42
+
43
+ /**
44
+ * Cache key: hash of everything that determines the response.
45
+ * @param {object} material what the adapter will send (config + request, or deployment + request)
46
+ * @param {{ scope?: 'tenant'|'user'|'global', tenant?: string|null, userId?: string|null }} [opts]
47
+ */
48
+ function cacheKey (material, { scope = 'tenant', tenant = null, userId = null } = {}) {
49
+ const scopeKey = scope === 'global' ? null : scope === 'user' ? { tenant, userId } : { tenant }
50
+ return sha256(stableStringify({ v: 1, scope: scopeKey, material }))
51
+ }
52
+
53
+ /**
54
+ * Whether a call may use the cache: enabled, non-streaming, and temperature at most
55
+ * `maxTemperature`. An unset temperature means the provider default (usually 1), so it is
56
+ * not cached unless `maxTemperature` is null.
57
+ * @param {object} cacheCfg
58
+ * @param {object} params effective model params
59
+ */
60
+ function isCacheable (cacheCfg, params) {
61
+ if (!cacheCfg?.enabled) return false
62
+ if (cacheCfg.maxTemperature == null) return true
63
+ const t = params?.temperature
64
+ return typeof t === 'number' && t <= cacheCfg.maxTemperature
65
+ }
66
+
67
+ module.exports = { MemoryCacheStore, cacheKey, isCacheable }