@viveka10/cap-ai-token-optimizer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +746 -2
- package/SECURITY.md +39 -0
- package/cds-plugin.js +50 -0
- package/db/token-usage-log.cds +44 -0
- package/docs/integration-guide.md +140 -0
- package/index.d.ts +291 -0
- package/index.js +36 -0
- package/lib/cache/index.js +67 -0
- package/lib/client/adapters.js +263 -0
- package/lib/client/instrument.js +124 -0
- package/lib/config.js +128 -0
- package/lib/exporters/console.js +18 -0
- package/lib/exporters/db.js +169 -0
- package/lib/exporters/http.js +143 -0
- package/lib/exporters/index.js +42 -0
- package/lib/exporters/otel.js +126 -0
- package/lib/metrics/classifier.js +175 -0
- package/lib/metrics/collector.js +206 -0
- package/lib/metrics/events.js +124 -0
- package/lib/optimizer.js +467 -0
- package/lib/pipeline/index.js +90 -0
- package/lib/pipeline/messages.js +62 -0
- package/lib/pipeline/steps/dedupe.js +103 -0
- package/lib/pipeline/steps/history.js +63 -0
- package/lib/pipeline/steps/normalize.js +88 -0
- package/lib/pipeline/steps/outputCap.js +28 -0
- package/lib/pipeline/steps/tools.js +157 -0
- package/lib/pipeline/text.js +166 -0
- package/lib/tokens/estimator.js +163 -0
- package/lib/util/cds.js +13 -0
- package/lib/util/hash.js +40 -0
- package/lib/util/log.js +37 -0
- package/lib/util/peer.js +55 -0
- package/package.json +131 -4
- package/srv/admin-service.cds +83 -0
- package/srv/admin-service.js +41 -0
- package/srv/admin-usage-log.cds +8 -0
package/README.md
CHANGED
|
@@ -1,3 +1,747 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @viveka10/cap-ai-token-optimizer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@viveka10/cap-ai-token-optimizer)
|
|
4
|
+
[](https://github.com/viveka10/npm_token_optimizer/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/viveka10/npm_token_optimizer/blob/main/cap-ai-token-optimizer/LICENSE)
|
|
6
|
+

|
|
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.
|