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