@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/README.md CHANGED
@@ -1,3 +1,747 @@
1
- # Temporary Holding Version
1
+ # @viveka10/cap-ai-token-optimizer
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@viveka10/cap-ai-token-optimizer)](https://www.npmjs.com/package/@viveka10/cap-ai-token-optimizer)
4
+ [![CI](https://github.com/viveka10/npm_token_optimizer/actions/workflows/ci.yml/badge.svg)](https://github.com/viveka10/npm_token_optimizer/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/LICENSE)
6
+ ![Node](https://img.shields.io/node/v/@viveka10/cap-ai-token-optimizer)
7
+
8
+ Reduce, measure and report the tokens your SAP CAP Node.js app spends on SAP AI Core.
9
+
10
+ - **Optimize** prompts without changing what they mean: whitespace and JSON, duplicate
11
+ instructions, long histories, large tool lists, output limits, and an exact-match response cache.
12
+ - **Measure** every AI Core call: tokens, estimated savings, latency, cost, cache hits and
13
+ classified failures, per tenant, use case and model.
14
+ - **Report** to the console, an HTTP endpoint or BTP destination, OpenTelemetry
15
+ (SAP Cloud ALM, Dynatrace, …), a database table, and a read-only admin service.
16
+
17
+ Works with CAP apps, including CAP apps that act as MCP servers or AI agents, and with plain
18
+ Node.js. Wraps the SAP Cloud SDK for AI (`@sap-ai-sdk/orchestration`, `@sap-ai-sdk/foundation-models`).
19
+
20
+ > **Status: pre-release.** Not affiliated with or endorsed by SAP.
21
+
22
+ **New here?** [Add it to an existing CAP app in 5 minutes](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/docs/integration-guide.md): no code changes needed.
23
+
24
+ **Contents:** [Requirements](#requirements) · [Setup](#setup) · [Usage](#usage) ·
25
+ [Existing code and LangChain](#instrumenting-existing-code-and-langchain) · [Compatibility](#compatibility) ·
26
+ [How prompts are optimized](#how-prompts-are-optimized) · [Configuration reference](#configuration-reference) ·
27
+ [What is recorded](#what-is-recorded) · [Exporters](#exporters) · [Admin service](#admin-service) ·
28
+ [SAP Cloud ALM setup](#sap-cloud-alm-setup) · [Troubleshooting](#troubleshooting) ·
29
+ [Development](#development)
30
+
31
+ ## Requirements
32
+
33
+ - Node.js ≥ 22.12. The SAP Cloud SDK for AI is ESM-only, and Node ≥ 22.12 can `require()` it.
34
+ - `@sap-ai-sdk/orchestration` and/or `@sap-ai-sdk/foundation-models` 2.8 or later (2.x)
35
+ - `@sap/cds` 9 or 10 (optional: the optimizer also works without CAP)
36
+ - Optional: `@sap-ai-sdk/langchain` 2.16+, `@cap-js/telemetry` for OpenTelemetry, a database
37
+ for the usage log
38
+
39
+ See [Compatibility](#compatibility) for the tested versions.
40
+
41
+ ## Setup
42
+
43
+ ```sh
44
+ npm add @viveka10/cap-ai-token-optimizer @sap-ai-sdk/orchestration
45
+ ```
46
+
47
+ In a CAP app the package is a CAP plugin: it is loaded automatically, reads
48
+ `cds.requires.tokenOptimizer`, starts the exporters and flushes them on shutdown.
49
+ Without any configuration it optimizes in `active` mode and writes one JSON line per call
50
+ to the console.
51
+
52
+ To roll out safely, start in shadow mode, which measures what would be saved but sends
53
+ your prompts unchanged:
54
+
55
+ ```json
56
+ {
57
+ "cds": {
58
+ "requires": {
59
+ "tokenOptimizer": {
60
+ "mode": "shadow",
61
+ "pricing": { "gpt-4.1": { "inputPer1k": 0.0, "outputPer1k": 0.0 } }
62
+ }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ Fill in `pricing` with the rates from your contract to get cost estimates.
69
+
70
+ AI Core credentials are read by the SAP Cloud SDK for AI as usual: from an `aicore` service
71
+ binding on BTP, `cds bind --exec` locally, or the `AICORE_SERVICE_KEY` environment variable.
72
+
73
+ ## Usage
74
+
75
+ ```js
76
+ const { optimizer } = require('@viveka10/cap-ai-token-optimizer')
77
+ const { OrchestrationClient } = require('@sap-ai-sdk/orchestration')
78
+
79
+ // Option A: wrap an existing client. Same API as the original.
80
+ const client = optimizer.wrap(new OrchestrationClient({
81
+ promptTemplating: { model: { name: 'gpt-4.1', params: { temperature: 0 } } }
82
+ }), { useCase: 'invoice-agent' })
83
+ const res = await client.chatCompletion({ messages: [{ role: 'user', content: 'Hi' }] })
84
+
85
+ // Option B: explicit call (orchestration by default, or sdk: 'foundation-models')
86
+ const { content, toolCalls, usage, response } = await optimizer.chat({
87
+ useCase: 'invoice-agent', model: 'gpt-4.1', messages, tools, maxTokens: 256, temperature: 0
88
+ })
89
+
90
+ // Inspect
91
+ optimizer.stats() // aggregated in-memory stats
92
+ optimizer.on('call', event => { /* one event per AI call */ })
93
+ optimizer.on('advisory', advisory => { /* prompt-caching advisories */ })
94
+ ```
95
+
96
+ Wrap clients once (for example in your service's `init()`) and reuse them.
97
+
98
+ - **Supported clients:** `OrchestrationClient` (`chatCompletion`, `stream`) and
99
+ `AzureOpenAiChatClient` (`run`, `stream`). Other methods are forwarded untouched.
100
+ - **Streaming:** `stream()` calls are passed through unoptimized and recorded without token usage.
101
+ - **Errors:** SDK errors are rethrown unchanged. Use `classifyError(err)` to get the failure category.
102
+
103
+ ```js
104
+ const { classifyError } = require('@viveka10/cap-ai-token-optimizer')
105
+ try { await client.chatCompletion(req) } catch (err) {
106
+ const { category } = classifyError(err) // e.g. 'RATE_LIMIT'
107
+ }
108
+ ```
109
+
110
+ `useCase` groups calls in stats and lets you configure them separately
111
+ (`useCases.<name>`). Pass `cdsService` to `wrap()`/`chat()` to record the CDS service name;
112
+ otherwise the request path from `cds.context` is used.
113
+
114
+ ## Instrumenting existing code and LangChain
115
+
116
+ ### autoInstrument
117
+
118
+ Optimize and measure every call your app already makes, without code changes:
119
+
120
+ ```json
121
+ "tokenOptimizer": { "autoInstrument": true, "mode": "shadow" }
122
+ ```
123
+
124
+ This hooks the `@sap-ai-sdk` client classes `OrchestrationClient` (`chatCompletion`,
125
+ `stream`) and `AzureOpenAiChatClient` (`run`, `stream`), including the ones that
126
+ `@sap-ai-sdk/langchain` creates internally.
127
+
128
+ - **No double counting:** explicitly wrapped clients are measured once.
129
+ - **Off by default:** with `autoInstrument` off, the hooks are either not installed or call
130
+ the original methods unchanged.
131
+ - **Use case:** calls without a use case are recorded as `autoInstrument.useCase`
132
+ (default `default`).
133
+ - **Limits:** see the [integration guide](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/docs/integration-guide.md#limits-of-automatic-instrumentation).
134
+ In short:
135
+ - only these two classes from SDK 2.x, and only the SDK copy resolved from the app root;
136
+ - process-wide, including libraries;
137
+ - streaming is measured but not optimized.
138
+
139
+ ### withUseCase
140
+
141
+ Label the calls made inside a function (any client, also ones created by libraries) without
142
+ changing the code that makes them:
143
+
144
+ ```js
145
+ this.on('summarizeTicket', req =>
146
+ optimizer.withUseCase('ticket-summary', () => summarize(req.data.ID), { cdsService: 'TicketService' }))
147
+ ```
148
+
149
+ ### LangChain
150
+
151
+ `@sap-ai-sdk/langchain` models create a new SDK client on every call, so wrap the model
152
+ instead of a client:
153
+
154
+ ```js
155
+ const { OrchestrationClient } = require('@sap-ai-sdk/langchain')
156
+ const model = optimizer.wrapLangChain(
157
+ new OrchestrationClient({ promptTemplating: { model: { name: 'gpt-4.1', params: { temperature: 0 } } } }),
158
+ { useCase: 'support-agent' }
159
+ )
160
+ await model.invoke('…') // also batch(), stream(), bindTools(), withStructuredOutput()
161
+ ```
162
+
163
+ The same model object is returned. Its calls go through the optimizer, so bound tools are
164
+ reduced by the `tools` step and identical low-temperature calls are cached. Works for
165
+ `OrchestrationClient` and `AzureOpenAiChatClient`. `wrapLangChain` works whether or not
166
+ `autoInstrument` is on.
167
+
168
+ ## Compatibility
169
+
170
+ Tested on 10 October 2026 with the Jest suite (278 tests: unit tests, integration tests with
171
+ the mock AI Core server, the real SDK, `@sap-ai-sdk/langchain` and the sample CAP app) and the
172
+ smoke test (`npm run test:smoke`: the real SDK, LangChain and the sample app, run with Node's
173
+ built-in test runner):
174
+
175
+ | Node.js | @sap/cds | @sap-ai-sdk/* | Jest suite | Smoke test |
176
+ |---|---|---|---|---|
177
+ | 24.17 | 10.1.1 | 2.16.0 | ✅ 278/278 | ✅ 5/5 |
178
+ | 24.17 | 10.1.1 | 2.12.0 | ✅ 278/278 | |
179
+ | 24.17 | 10.1.1 | 2.8.0 | ✅ 278/278 | |
180
+ | 24.17 | 9.9.3 | 2.16.0 | ✅ 278/278 | |
181
+ | 24.17 | 9.9.3 | 2.8.0 | ✅ 278/278 | ✅ 5/5 |
182
+ | 22.23.3 | 10.1.1 | 2.16.0 | n/a¹ | ✅ 5/5 |
183
+ | 22.23.3 | 9.9.3 | 2.8.0 | n/a¹ | ✅ 5/5 |
184
+ | 22.12.0 | 10.1.1 | 2.16.0 | n/a¹ | ✅ 4/4, sample app skipped² |
185
+ | 22.12.0 | 9.9.3 | 2.8.0 | n/a¹ | ✅ 4/4, sample app skipped² |
186
+
187
+ - **¹ Jest on Node 22:** Jest can only `require()` the ESM-only SDK on Node ≥ 24.9, so the Jest
188
+ suite runs on Node 24. The package itself runs on Node 22.12+; the smoke test covers it there.
189
+ - **² Sample app on Node 22.12:** CAP's `@cap-js/sqlite` 3 needs `node:sqlite` features that
190
+ Node 22.12 doesn't have, so the sample app's in-memory database can't start. This affects
191
+ local SQLite only, not this package.
192
+ - **Node 22.12 warning:** it prints an `ExperimentalWarning` when the SDK is loaded with
193
+ `require()`. It is harmless; later Node 22 releases don't print it.
194
+ - **`@sap-ai-sdk` before 2.8:** basic optimization works, but `requestId` is missing from
195
+ events, and orchestration fallback lists need 2.8 (2.0 to 2.7 fail 1 to 4 of the 278 tests).
196
+ Use 2.8 or later.
197
+ - **Version alignment:** all `@sap-ai-sdk/*` packages in an app must have the same version.
198
+ - **LangChain:** tested with `@sap-ai-sdk/langchain` 2.16.0, `@langchain/core` 1.2.12 and
199
+ `langchain` 1.5.12.
200
+
201
+ The CI workflow ([`.github/workflows/ci.yml`](https://github.com/viveka10/npm_token_optimizer/blob/main/.github/workflows/ci.yml)) runs this matrix on every push and pull request.
202
+
203
+ ## How prompts are optimized
204
+
205
+ Each step can be switched off and reports the prompt tokens it saved (`savings.byStep`).
206
+ They run in this order:
207
+
208
+ | Step | What it does | Never touches |
209
+ |---|---|---|
210
+ | `normalize` | Trims, collapses repeated spaces and blank lines, minifies JSON blocks, ```` ```json ```` fences and tool-call arguments. | Fenced code, inline code, indented lines, tabs, JSON string values, numbers and key order |
211
+ | `dedupe` | Drops repeated system messages and an identical user message sent twice in a row. Removes paragraphs (≥ 30 chars) that repeat a system paragraph, and older copies of paragraphs a later user message repeats. | Tool and assistant messages, messages with code fences; never empties a message |
212
+ | `history` | Keeps system messages and the last `maxTurns` turns. A turn starts at a user message, so tool calls stay with their results. Pluggable `strategy` and optional `summarizer` hook (no LLM summarizer is built in). | System messages |
213
+ | `tools` | Above `maxTools`, keeps the tools most relevant to the latest user message (keyword overlap; pluggable `selector`). Shortens tool and parameter descriptions longer than `maxDescriptionLength`. | `pinned` tools, tools already used in the conversation, the tool forced by `tool_choice` |
214
+ | `outputCap` | Sets `max_tokens` when the caller set none. Reasoning models (o-series, gpt-5) are skipped unless `reasoningModels: true`. | A limit the caller set |
215
+ | cache | Exact-match response cache (in-memory LRU with TTL, or your own `CacheStore`). | Streaming calls, failed calls, `temperature` above `maxTemperature` or unset |
216
+
217
+ - **Orchestration:** the client's template messages are never changed. When tools or model
218
+ parameters change, the call goes through a copy of your client built with the optimized
219
+ config; your client object is not modified. Clients built from a fallback list, a JSON string
220
+ or a stored config reference keep their tools and parameters (the event notes this).
221
+ - **Failures:** if any step fails, the original request is sent and the event has
222
+ `savings.failed: true`.
223
+
224
+ ### Modes
225
+
226
+ | Mode | Sends | Cache | Savings in events |
227
+ |---|---|---|---|
228
+ | `active` | optimized request | serves hits | actual |
229
+ | `shadow` | original request | never serves; records would-be hits | what active mode would have saved |
230
+ | `off` | original request | not used | none (calls are still measured) |
231
+
232
+ Start with `shadow` in production, compare `savings` and answer quality, then switch to
233
+ `active`, possibly per use case (`useCases.<name>.mode`).
234
+
235
+ ### Cache
236
+
237
+ - **Key:** the full model config and request, plus the tenant (`scope: "tenant"`, the
238
+ default), the tenant and user (`"user"`) or nothing (`"global"`).
239
+ - **What is cached:** only successful, non-streaming calls with `temperature` ≤
240
+ `maxTemperature` (default 0.2). A call without `temperature` uses the provider default
241
+ (usually 1) and is not cached. Set `maxTemperature: null` to cache regardless.
242
+ - **Cache hits:** recorded with zero tokens, `savings.cacheHit: true`, and the cached call's
243
+ tokens in `savings.byStep.cache`.
244
+ - **Other stores:** the in-memory store is per app instance. For a shared cache, pass a
245
+ `CacheStore` (`get`, `set(key, value, ttlMs)`, sync or async) as `cache.store` in code.
246
+ Values are plain JSON.
247
+
248
+ ### Prompt-caching advisory
249
+
250
+ Providers cache the start of a prompt (system messages, tool definitions) and bill cached
251
+ tokens at a lower rate. When the system prompt or the tools sent for the same use case and
252
+ model change between calls, that cache can't be used. The optimizer then:
253
+
254
+ - sets `advisory.promptPrefixChanged` in the event, with the part that changed (`system`, `tools`);
255
+ - counts it in `stats().promptPrefixChanges` and the `cap_ai_optimizer.prompt_cache.prefix_changes` metric;
256
+ - emits an `advisory` event and logs a warning at most every 10 minutes per use case.
257
+
258
+ `causedByOptimizer: true` means your app sent the same prefix but an optimizer step changed
259
+ it. Usually this is the `tools` step picking different tools for different questions. If
260
+ prompt caching matters more than tool tokens for a use case, raise `maxTools`, pin the
261
+ tools, or turn the step off for that use case. Typical causes on the app side are dates,
262
+ user names or retrieved content in the system prompt. Move those to a user message.
263
+
264
+ ### Benchmark
265
+
266
+ ```sh
267
+ npm run bench # estimated savings per step for the prompt corpus (no network)
268
+ npm run bench -- --live # also call AI Core in off and active mode, save both answers
269
+ npm run bench -- --live --mock # the same against the local mock server
270
+ ```
271
+
272
+ `--live` saves both answers per conversation to `bench/results/<timestamp>/` so you can
273
+ compare quality. Use `--model <name>` for a model deployed in your tenant, `--filter <text>`
274
+ to select fixtures and `--config <file>` for an optimizer configuration. The corpus in
275
+ `test/fixtures/prompts/` has 12 agent conversations with long histories, repeated
276
+ instructions, 36 and 40 tool definitions, code review, RAG context and a compact control
277
+ prompt. These are the estimated savings with the default config:
278
+
279
+ ```
280
+ Fixture Before normalize dedupe history tools After Saved
281
+ 01-invoice-json-payload 1339 525 0 0 0 814 39.2%
282
+ 02-invoice-long-history 1154 0 0 774 0 380 67.1%
283
+ 03-mcp-s4hana-36-tools 2929 126 0 0 1163 1640 44.0%
284
+ 04-helpdesk-repeated-instructions 811 0 405 0 0 406 49.9%
285
+ 05-travel-expense-tool-results 2606 720 0 0 0 1886 27.6%
286
+ 06-hr-duplicate-system-prompt 423 0 66 0 0 357 15.6%
287
+ 07-procurement-40-tools-long-descriptions 10285 82 0 0 7231 2972 71.1%
288
+ 08-code-review-code-blocks 218 6 0 0 0 212 2.8%
289
+ 09-customer-service-rag-context 193 20 0 0 0 173 10.4%
290
+ 10-analytics-agent-large-results 6704 2340 0 1673 0 2691 59.9%
291
+ 11-control-compact-prompt 26 0 0 0 0 26 0.0%
292
+ 12-multilingual-support-long 1749 0 728 617 0 404 76.9%
293
+ TOTAL 28437 3819 1199 3064 8394 11961 57.9%
294
+ ```
295
+
296
+ These are estimates on a synthetic corpus. Removing history and tools trades context for
297
+ tokens: check answer quality with `--live` and tune `maxTurns` and `maxTools` per use case.
298
+
299
+ ## Configuration reference
300
+
301
+ Configure in `package.json` (`cds.requires.tokenOptimizer`), `.cdsrc.json`, or environment
302
+ variables (e.g. `cds_requires_tokenOptimizer_mode=shadow`). Profiles such as `[production]`
303
+ work as usual. Values not set use the defaults below.
304
+
305
+ ```json
306
+ "tokenOptimizer": {
307
+ "mode": "active",
308
+ "steps": {
309
+ "normalize": true,
310
+ "dedupe": true,
311
+ "history": { "maxTurns": 10 },
312
+ "tools": { "maxTools": 15, "maxDescriptionLength": 600, "pinned": [] },
313
+ "outputCap": { "default": 1024 }
314
+ },
315
+ "cache": { "enabled": true, "ttlSeconds": 3600, "maxEntries": 1000, "maxTemperature": 0.2, "scope": "tenant" },
316
+ "metrics": {
317
+ "exporters": ["console"],
318
+ "flushIntervalMs": 15000,
319
+ "userHashSalt": "",
320
+ "http": { "url": "", "destination": "", "headers": {} },
321
+ "otel": { "enabled": false, "tenantAttribute": true },
322
+ "db": { "batchSize": 50, "maxQueueSize": 10000, "retentionDays": 30 }
323
+ },
324
+ "pricing": {},
325
+ "useCases": {},
326
+ "timeoutMs": null,
327
+ "retry": { "enabled": false, "maxRetries": 2, "baseDelayMs": 500, "maxDelayMs": 10000,
328
+ "on": ["RATE_LIMIT", "SERVER", "NETWORK", "TIMEOUT"] },
329
+ "autoInstrument": { "enabled": false, "useCase": "default" },
330
+ "advisories": { "promptCache": true },
331
+ "admin": { "enabled": false, "role": "TokenOptimizerAdmin" },
332
+ "captureContent": false
333
+ }
334
+ ```
335
+
336
+ ### General
337
+
338
+ | Key | Default | Meaning |
339
+ |---|---|---|
340
+ | `mode` | `"active"` | `active`, `shadow` or `off`. See [Modes](#modes). |
341
+ | `useCases.<name>` | `{}` | Any of these settings for one use case, merged over the global ones. Example: `{ "agent": { "steps": { "tools": { "maxTools": 10 } } } }`. |
342
+ | `pricing.<model>` | `{}` | `inputPer1k`, `outputPer1k`, optional `cachedInputPer1k` (defaults to `inputPer1k`). Model names with an AI Core prefix (`azure-openai--gpt-4.1`) also match `gpt-4.1`. |
343
+ | `timeoutMs` | `null` | Request timeout for calls that set none. `null` uses the SDK default. |
344
+ | `retry.enabled` | `false` | Retry failed calls in the wrapper. The SDK itself does not retry. |
345
+ | `retry.maxRetries` | `2` | Retries after the first attempt. |
346
+ | `retry.baseDelayMs` / `maxDelayMs` | `500` / `10000` | Exponential backoff with jitter; `Retry-After` is used when present (capped at `maxDelayMs`). |
347
+ | `retry.on` | `RATE_LIMIT, SERVER, NETWORK, TIMEOUT` | Failure categories to retry. |
348
+ | `autoInstrument` | `false` | `true`, or `{ "enabled": true, "useCase": "default" }`. See [autoInstrument](#autoinstrument). |
349
+ | `advisories.promptCache` | `true` | [Prompt-caching advisory](#prompt-caching-advisory). |
350
+ | `captureContent` | `false` | Adds the messages that were sent and the completion to events (`content`). Keep it off in production: it is personal data. Never written to the database. |
351
+
352
+ ### Steps
353
+
354
+ Each step accepts `false` (off), `true` (defaults) or an options object (`{ "enabled": false }` also turns it off).
355
+
356
+ | Key | Default | Meaning |
357
+ |---|---|---|
358
+ | `steps.normalize.minifyJson` | `true` | Minify JSON blocks that start a line, JSON tool messages and tool-call arguments. |
359
+ | `steps.normalize.minifyJsonFences` | `true` | Minify the content of ```` ```json ```` fences. |
360
+ | `steps.normalize.collapseSpaces` | `true` | Collapse runs of spaces in prose (not indentation, inline code, tabs). |
361
+ | `steps.dedupe.messages` | `true` | Drop repeated system messages and an identical consecutive user message. |
362
+ | `steps.dedupe.paragraphs` | `true` | Drop repeated instruction paragraphs. |
363
+ | `steps.dedupe.minParagraphLength` | `30` | Shorter paragraphs are never removed. |
364
+ | `steps.history.maxTurns` | `10` | Turns to keep. `0` keeps all. |
365
+ | `steps.history.strategy` | `"sliding-window"` | Or a function `({ messages, maxTurns, model }) => messages` (in code). |
366
+ | `steps.history.summarizer` | `null` | Function `async ({ dropped, kept, model }) => message \| null` (in code). |
367
+ | `steps.tools.maxTools` | `15` | Tools to keep when there are more. |
368
+ | `steps.tools.maxDescriptionLength` | `600` | Longer tool and parameter descriptions are shortened. `0` keeps them. |
369
+ | `steps.tools.pinned` | `[]` | Tool names that are always kept. |
370
+ | `steps.tools.selector` | keyword overlap | Function `({ tools, messages, maxTools, keep }) => tools` (in code). |
371
+ | `steps.outputCap.default` | `1024` | `max_tokens` for calls that set no limit. |
372
+ | `steps.outputCap.reasoningModels` | `false` | Also cap reasoning models. |
373
+
374
+ ### Cache
375
+
376
+ | Key | Default | Meaning |
377
+ |---|---|---|
378
+ | `cache.enabled` | `true` | |
379
+ | `cache.ttlSeconds` | `3600` | |
380
+ | `cache.maxEntries` | `1000` | Size of the in-memory LRU. |
381
+ | `cache.maxTemperature` | `0.2` | Only calls with `temperature` at or below are cached. `null`: any. |
382
+ | `cache.scope` | `"tenant"` | `tenant`, `user` or `global`. |
383
+ | `cache.store` | in-memory | A `CacheStore` object (in code). |
384
+
385
+ ### Metrics and exporters
386
+
387
+ | Key | Default | Meaning |
388
+ |---|---|---|
389
+ | `metrics.exporters` | `["console"]` | `console`, `http`, `otel`, `db` (alias `hana`), or exporter objects (in code). |
390
+ | `metrics.flushIntervalMs` | `15000` | How often buffering exporters (http, db) send. |
391
+ | `metrics.userHashSalt` | `""` | Salt for the user-id hash. Set it from the environment, not in committed files. |
392
+ | `metrics.http.url` / `destination` | | Target URL, or a BTP destination name. One is required for `http`. |
393
+ | `metrics.http.headers` | `{}` | Extra headers. Don't commit secrets; see [http](#http). |
394
+ | `metrics.http.batchSize` | `100` | Events per POST. |
395
+ | `metrics.http.maxQueueSize` | `10000` | When full, the oldest events are dropped. |
396
+ | `metrics.http.maxRetries` | `3` | Retries per batch per flush. |
397
+ | `metrics.http.timeoutMs` | `10000` | Per request. |
398
+ | `metrics.http.baseDelayMs` / `maxDelayMs` | `1000` / `30000` | Backoff between retries. |
399
+ | `metrics.http.destinationCacheMs` | `300000` | How long a resolved destination is reused. |
400
+ | `metrics.otel.enabled` | `false` | Same as adding `otel` to `exporters`. |
401
+ | `metrics.otel.tenantAttribute` | `true` | Add `sap.tenancy.tenant_id` to metrics and spans. |
402
+ | `metrics.db.batchSize` | `50` | Rows per insert. |
403
+ | `metrics.db.maxQueueSize` | `10000` | When full, the oldest rows are dropped. |
404
+ | `metrics.db.retentionDays` | `30` | Older rows are deleted (checked at most daily per tenant). `0` keeps all. |
405
+
406
+ ### Admin service
407
+
408
+ | Key | Default | Meaning |
409
+ |---|---|---|
410
+ | `admin.enabled` | `false` | Serve the [admin service](#admin-service). |
411
+ | `admin.role` | `"TokenOptimizerAdmin"` | Role required to read it. |
412
+
413
+ ### Configuring in code
414
+
415
+ Functions and stores can only be set in code. `configure()` replaces the whole configuration:
416
+
417
+ ```js
418
+ const cds = require('@sap/cds')
419
+ const { optimizer } = require('@viveka10/cap-ai-token-optimizer')
420
+ const base = cds.env.requires.tokenOptimizer
421
+
422
+ optimizer.configure({
423
+ ...base,
424
+ steps: { ...base.steps, tools: { ...base.steps?.tools, selector: myEmbeddingSelector } },
425
+ cache: { ...base.cache, store: myRedisStore }
426
+ })
427
+ ```
428
+
429
+ The `db` exporter and the admin service also need their CDS models, which the plugin adds
430
+ only when they are enabled in `cds.env` at startup. Enable them in the configuration, not
431
+ only in code.
432
+
433
+ `createOptimizer(config)` creates a separate instance with its own stats and exporters.
434
+
435
+ ## What is recorded
436
+
437
+ One event per call:
438
+
439
+ ```json
440
+ { "schemaVersion": 1, "id": "…", "timestamp": "2026-10-10T09:46:29.190Z",
441
+ "tenant": "t1", "user": "3b8d32346718b5af", "cdsService": "AgentService",
442
+ "useCase": "invoice-agent", "mode": "active", "model": "gpt-4.1",
443
+ "deploymentId": null, "resourceGroup": "default", "sdk": "orchestration", "operation": "chat",
444
+ "requestId": "…", "finishReason": "stop", "estimateMethod": "tiktoken",
445
+ "tokens": { "promptEstimatedBefore": 2015, "promptEstimatedAfter": 805,
446
+ "prompt": 805, "completion": 41, "total": 846, "cached": 0 },
447
+ "savings": { "estimatedTokens": 1210,
448
+ "byStep": { "normalize": 96, "dedupe": 340, "history": 774, "tools": 0, "outputCap": 0 },
449
+ "cacheHit": false,
450
+ "notes": ["history: 40 message(s) outside the last 10 turns removed"], "failed": false },
451
+ "costEstimate": 0.001938, "latencyMs": 640, "retries": 0, "status": "success", "error": null,
452
+ "promptHash": "<sha256>", "promptLength": 3120,
453
+ "advisory": { "promptPrefixChanged": false, "changed": [], "causedByOptimizer": false } }
454
+ ```
455
+
456
+ - Prompt and completion text are never recorded by default; only a SHA-256 hash and the length.
457
+ `user` is a salted hash of the user id.
458
+ - `tokens.prompt`/`completion`/`total`/`cached` come from the AI Core response.
459
+ `promptEstimatedBefore`/`After` are estimates before and after optimization (js-tiktoken for
460
+ OpenAI model families, characters ÷ 4 for other models).
461
+ - `savings.estimatedTokens` is estimated prompt tokens saved plus, for cache hits, the cached
462
+ call's tokens. `outputCap` saves output tokens, which can't be known in advance, so it reports 0.
463
+ - Failures have `status: "error"` and `error: { category, httpStatus, code, message }` with
464
+ `category` one of `RATE_LIMIT`, `CONTENT_FILTER`, `CONTEXT_LENGTH`, `TIMEOUT`, `AUTH`,
465
+ `DEPLOYMENT_NOT_FOUND`, `SERVER`, `NETWORK`, `UNKNOWN`. The message is truncated to 300 characters.
466
+ - Metrics, listener and exporter failures are logged and never break the AI call.
467
+
468
+ `optimizer.stats()` returns totals since start: calls, success, errors, cacheHits, tokens,
469
+ savedTokensEstimate, costEstimate, errorsByCategory, savingsByStep, latency percentiles,
470
+ byUseCase, byModel, promptPrefixChanges, and per-exporter stats and errors.
471
+
472
+ ## Exporters
473
+
474
+ All exporters run outside the AI call. A failing exporter is logged and counted in
475
+ `stats().exporterErrors`; it never throws to the caller.
476
+
477
+ ### console
478
+
479
+ The default. Writes one JSON line per event to stdout, `{"type":"ai-core-call", ...}`.
480
+
481
+ ### http
482
+
483
+ POSTs batches as `{ "events": [ ... ] }` to a URL or a BTP destination:
484
+
485
+ ```json
486
+ "metrics": { "exporters": ["console", "http"], "http": { "destination": "TOKEN_METRICS" } }
487
+ ```
488
+
489
+ - **Destinations:** resolved with `@sap-cloud-sdk/connectivity` (installed with
490
+ `@sap-ai-sdk/*`), including their authentication headers, and cached for 5 minutes. Only
491
+ Internet destinations are supported. Locally, the `destinations` environment variable works as usual.
492
+ - **When it sends:** on the flush interval, when a batch is full, and on shutdown.
493
+ - **Retries:** 429, 5xx, 401 and network errors are retried with backoff (`Retry-After` is
494
+ honoured); other 4xx drop the batch. Batches that still fail stay queued for the next flush.
495
+ - **Queue limit:** capped at `maxQueueSize`; the oldest events are dropped when it is full.
496
+ `stats().exporters.http` shows `queued`, `sent`, `dropped`, `failedAttempts`, `rejectedBatches`.
497
+ - **Secrets:** don't put secrets in `headers` in committed files. Prefer a destination, or set
498
+ headers from the environment (`cds_requires_tokenOptimizer_metrics_http_headers_Authorization=…`)
499
+ or `.cdsrc-private.json`.
500
+
501
+ ### otel
502
+
503
+ OpenTelemetry metrics and spans for SAP Cloud ALM, Dynatrace or any OTLP backend.
504
+
505
+ ```sh
506
+ npm add @cap-js/telemetry
507
+ ```
508
+ ```json
509
+ "metrics": { "exporters": ["console", "otel"] }
510
+ ```
511
+
512
+ The exporter uses only `@opentelemetry/api` and the providers that `@cap-js/telemetry` (or the
513
+ SAP Cloud ALM agent) registers. It never creates its own SDK, so where data goes is
514
+ configured in `cds.requires.telemetry`. Without registered providers it logs a warning and
515
+ records nothing.
516
+
517
+ | Metric | Type | Extra attributes |
518
+ |---|---|---|
519
+ | `gen_ai.client.token.usage` | histogram `{token}` | `gen_ai.token.type` = `input` / `output` |
520
+ | `gen_ai.client.operation.duration` | histogram `s` | `error.type` on failures |
521
+ | `cap_ai_optimizer.calls` | counter | `error.type` on failures |
522
+ | `cap_ai_optimizer.errors` | counter | `error.type` = failure category |
523
+ | `cap_ai_optimizer.tokens.saved` | counter `{token}` | `cap_ai_optimizer.step` |
524
+ | `cap_ai_optimizer.cache.hits` | counter | |
525
+ | `cap_ai_optimizer.cost` | counter | in the currency of your `pricing` |
526
+ | `cap_ai_optimizer.prompt_cache.prefix_changes` | counter | `cap_ai_optimizer.prefix_part`, `cap_ai_optimizer.caused_by_optimizer` |
527
+
528
+ All carry `gen_ai.operation.name`, `gen_ai.provider.name` (`sap.ai_core`),
529
+ `gen_ai.request.model`, `cap_ai_optimizer.use_case`, `.mode`, `.sdk` and
530
+ `sap.tenancy.tenant_id`. The first two metrics follow the OpenTelemetry GenAI semantic conventions.
531
+
532
+ The exporter also creates one span per call, `chat <model>` (kind CLIENT), as a child of the
533
+ current span (for example the CAP request span from `@cap-js/telemetry`). It carries token
534
+ usage, savings, cache hit, AI Core request id, deployment and error attributes, but never
535
+ prompt or completion text. The span is recorded when the call ends, with its real start and
536
+ end time, so the SDK's HTTP spans are its siblings, not its children.
537
+
538
+ ### db
539
+
540
+ The usage log for in-app reporting:
541
+
542
+ ```json
543
+ "metrics": { "exporters": ["console", "db"] }
544
+ ```
545
+
546
+ Writes one row per call to the entity `cap.ai.token_optimizer.TokenUsageLog`
547
+ (table `CAP_AI_TOKEN_OPTIMIZER_TOKENUSAGELOG`) through CAP's database service.
548
+
549
+ - **Databases:** SAP HANA, SQLite or PostgreSQL. `hana` is an alias for `db`.
550
+ - **Model:** the plugin adds the entity to your model when the exporter is configured.
551
+ Deploy as usual (`cds deploy`, or the MTA db deployer), and run your MTX upgrade for multitenant apps.
552
+ - **Multitenancy:** rows are inserted in batches on the flush interval with a privileged
553
+ user. Each tenant's rows go to its own container.
554
+ - **Content:** only metrics, never prompt or completion text.
555
+ - **Retention:** rows older than `retentionDays` (default 30) are deleted.
556
+ - **Stats:** `stats().exporters.db` shows `queued`, `written`, `dropped`, `failedWrites`, `deleted`.
557
+
558
+ Query it from your own services, for example:
559
+
560
+ ```cds
561
+ using { cap.ai.token_optimizer.TokenUsageLog } from '@viveka10/cap-ai-token-optimizer/db/token-usage-log';
562
+ service ReportingService { @readonly entity AiUsage as projection on TokenUsageLog; }
563
+ ```
564
+
565
+ ### Custom exporters
566
+
567
+ Pass an object with `export(event)` and optionally `flush()`, `shutdown()`, `stats()` in
568
+ `metrics.exporters` (in code).
569
+
570
+ ## Admin service
571
+
572
+ ```json
573
+ "tokenOptimizer": { "admin": { "enabled": true } }
574
+ ```
575
+
576
+ Serves the read-only OData V4 service `TokenOptimizerService` at `/odata/v4/token-optimizer`
577
+ for users with the role `admin.role` (default `TokenOptimizerAdmin`):
578
+
579
+ | Path | Content |
580
+ |---|---|
581
+ | `summary()` | totals, mode, latency percentiles, since |
582
+ | `UseCases`, `Models` | totals per use case / model |
583
+ | `ErrorCategories` | failures per category |
584
+ | `StepSavings` | estimated tokens saved per step |
585
+ | `PromptPrefixChanges` | prompt-caching advisories per use case |
586
+ | `Exporters` | errors and stats per exporter |
587
+ | `UsageLog` | the `TokenUsageLog` rows, if the `db` exporter is enabled (supports `$filter`, `$orderby`, `$top`, …) |
588
+
589
+ All entries except `UsageLog` are in-memory stats of the app instance that answers, since it started.
590
+
591
+ To grant access, add the role to `xs-security.json` and a role collection:
592
+
593
+ ```json
594
+ {
595
+ "scopes": [{ "name": "$XSAPPNAME.TokenOptimizerAdmin", "description": "Read token optimizer statistics" }],
596
+ "role-templates": [{ "name": "TokenOptimizerAdmin", "scope-references": ["$XSAPPNAME.TokenOptimizerAdmin"] }]
597
+ }
598
+ ```
599
+
600
+ ## SAP Cloud ALM setup
601
+
602
+ Cloud ALM receives the OpenTelemetry data through the SAP Cloud ALM agent. As documented in
603
+ the `@cap-js/telemetry` README ("Running with SAP Cloud ALM (beta)"):
604
+
605
+ 1. **Onboard the app to Cloud ALM.** Install and configure the agent extension
606
+ `@sap/xotel-agent-ext-js` (2.0.4 or later) as described in the SAP Cloud ALM documentation
607
+ (Expert Portal → Data Collection Infrastructure). The agent owns the OpenTelemetry SDK.
608
+ 2. **Add `@cap-js/telemetry` 2.1.0 or later:** `npm add @cap-js/telemetry`. It detects the
609
+ agent and adds its span processor, metric reader and log processor as delegates to the
610
+ agent instead of creating a second SDK. Version 2.0.x does not have this integration.
611
+ 3. **Enable the `otel` exporter:** `"metrics": { "exporters": ["console", "otel"] }`.
612
+ Its metrics and spans go through the same pipeline.
613
+ 4. **Deploy and check.** On startup `@cap-js/telemetry` logs that it found
614
+ `@sap/xotel-agent-ext-js`. The optimizer's spans appear under the CAP request spans.
615
+
616
+ Notes:
617
+
618
+ - The Cloud ALM integration in `@cap-js/telemetry` is beta. Running the Cloud ALM agent
619
+ together with Dynatrace OneAgent is not supported.
620
+ - Not yet verified against a Cloud ALM tenant.
621
+ - Without Cloud ALM, the same exporter works with `@cap-js/telemetry`'s other targets
622
+ (`to-dynatrace`, `to-cloud-logging`, `to-otlp`, …).
623
+
624
+ ## Troubleshooting
625
+
626
+ **`ERR_REQUIRE_ESM` / "require() of ES Module … @sap-ai-sdk"**
627
+ Node.js is older than 22.12. Upgrade Node (`"engines": { "node": ">=22.12" }` in your app, and
628
+ the buildpack or `node_version` on Cloud Foundry).
629
+
630
+ **Jest: "Must use import to load ES Module"**
631
+ Jest needs `node --experimental-vm-modules node_modules/jest/bin/jest.js` to load the SDK.
632
+
633
+ **"Missing peer dependency …" / `PEER_DEPENDENCY_MISSING`**
634
+ Install the named package in your app (e.g. `@sap-ai-sdk/foundation-models` for
635
+ `chat({ sdk: 'foundation-models' })`, `@opentelemetry/api` for the otel exporter).
636
+
637
+ **"@sap/cds was loaded from different locations" when starting or testing**
638
+ Two copies of `@sap/cds` are loaded. Common causes:
639
+
640
+ - **`file:` / `npm link` installs:** the linked package's own `node_modules` contains
641
+ `@sap/cds`. Run `npm dedupe`, or remove `node_modules` in the linked package.
642
+ - **SAP Business Application Studio:** `NODE_PATH` points to the global CAP tools. A missing
643
+ local package (often `@cap-js/sqlite`, needed by `cds watch` / `cds.test`) is then loaded
644
+ from there, together with its own `@sap/cds`. Install it locally: `npm add -D @cap-js/sqlite`.
645
+
646
+ **No cache hits**
647
+ Only calls with `temperature` ≤ `cache.maxTemperature` (0.2) are cached, and an unset
648
+ temperature is not. Streaming calls and failures are never cached. Cache keys include the
649
+ tenant, and any change in model config, messages or tools. In `shadow` mode the cache is
650
+ never served. Each app instance has its own in-memory cache.
651
+
652
+ **Note "tools: skipped, the client's tools are fixed"**
653
+ The orchestration client was built from a fallback list, a JSON string or a stored config
654
+ reference. Use a single inline config to let the `tools` and `outputCap` steps work.
655
+
656
+ **`model: null` in events**
657
+ The model isn't visible in the client config (stored config reference). Calls are unaffected.
658
+
659
+ **Answers are cut off or empty**
660
+ `outputCap` set `max_tokens` (default 1024) because the call set none. Set `max_tokens` in your
661
+ model params, or raise `steps.outputCap.default` for that use case.
662
+
663
+ **Answers lack earlier context or a tool**
664
+ Raise `steps.history.maxTurns` or `steps.tools.maxTools`, add tools to `pinned`, or turn the
665
+ step off for that use case. Use `shadow` mode and `npm run bench -- --live` to compare.
666
+
667
+ **Frequent "prompt prefix … changed" warnings**
668
+ See [Prompt-caching advisory](#prompt-caching-advisory).
669
+
670
+ **"otel exporter: no OpenTelemetry MeterProvider is registered"**
671
+ Install `@cap-js/telemetry`, and make sure it isn't disabled (`NO_TELEMETRY`) and has a
672
+ metrics exporter configured.
673
+
674
+ **"db exporter disabled: …"**
675
+ - *No service definition found for db:* the app has no database configured.
676
+ - *Entity … is not in the model:* the exporter was enabled only in code; enable it in
677
+ `cds.requires.tokenOptimizer.metrics.exporters`.
678
+
679
+ The exporter disables itself in both cases, and calls continue.
680
+
681
+ **http exporter: events are not arriving**
682
+ Check `stats().exporters.http`, or `Exporters` in the admin service:
683
+
684
+ - `rejectedBatches` > 0: the endpoint answered with a 4xx (see the log).
685
+ - `dropped` growing: the endpoint is down longer than the queue holds.
686
+ - `destination "…" not found`: check the destination name and the destination service binding.
687
+
688
+ **Admin service returns 403 / 404**
689
+ - 403: the user lacks `admin.role`.
690
+ - 404: `admin.enabled` is false, or was set only in code.
691
+
692
+ **Debug logging**
693
+ `DEBUG=token-optimizer`, or `"cds": { "log": { "levels": { "token-optimizer": "debug" } } }`,
694
+ shows retries and exporter attempts.
695
+
696
+ ## Sample app
697
+
698
+ [`sample-cap-agent`](https://github.com/viveka10/npm_token_optimizer/tree/main/sample-cap-agent) is a small CAP app using this package: an `ask`
699
+ action and an MCP-style agent with 32 tools and a multi-turn history. In the `development`
700
+ profile it starts the mock AI Core server; `hybrid` uses a real AI Core binding.
701
+
702
+ ## Development
703
+
704
+ ```sh
705
+ npm test # unit + integration tests with coverage (mock AI Core server, sample app)
706
+ npm run lint
707
+ npm run bench # token-savings benchmark
708
+ npm run mock:aicore # start the mock AI Core server and print an AICORE_SERVICE_KEY to use
709
+ npm run test:smoke # end-to-end smoke test with node:test (also runs on Node 22)
710
+ npm run test:live # real AI Core, see below
711
+ ```
712
+
713
+ Tests run Jest with `--experimental-vm-modules` so Jest can load the ESM-only SDK. The
714
+ sample-app tests need `npm install` in `../sample-cap-agent` and are skipped otherwise.
715
+
716
+ The mock AI Core server (`test/mock-aicore/server.js`) mimics the OAuth, deployment,
717
+ orchestration and chat-completion endpoints. It can answer normally, or with a rate limit
718
+ (429 with `Retry-After`), content filter, context length error, 500, 404, authentication
719
+ failure or a slow response. Pick a scenario with the `x-mock-scenario` header, a
720
+ `[[mock:<scenario>]]` marker in a message, or `POST /__mock/scenario`.
721
+
722
+ Live tests (`test/live/`) call a real SAP AI Core instance with one tiny prompt per model
723
+ and `max_tokens: 50`. They run only with `npm run test:live` and need credentials:
724
+
725
+ ```sh
726
+ AICORE_SERVICE_KEY='{...}' LIVE_TEST_MODELS=gpt-4.1 npm run test:live
727
+ cds bind --exec -- npm run test:live # in a CAP project with an aicore binding
728
+ ```
729
+
730
+ `LIVE_TEST_FM_MODELS` adds foundation-models deployments, and `AICORE_RESOURCE_GROUP`
731
+ selects the resource group.
732
+
733
+ ## Security
734
+
735
+ See [SECURITY.md](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/SECURITY.md) to report vulnerabilities and
736
+ for how the package handles data. Releases are published from GitHub Actions with
737
+ [npm provenance](https://docs.npmjs.com/generating-provenance-statements).
738
+
739
+ ## Contributing
740
+
741
+ See [CONTRIBUTING.md](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/CONTRIBUTING.md) and the
742
+ [CHANGELOG](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/CHANGELOG.md).
743
+
744
+ ## License
745
+
746
+ MIT. Not affiliated with or endorsed by SAP. SAP, SAP BTP, SAP HANA and SAP Cloud ALM are
747
+ trademarks of SAP SE.