@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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Pre-call token estimation.
|
|
4
|
+
* Uses js-tiktoken for OpenAI model families and a chars/4 heuristic for everything else
|
|
5
|
+
* (Anthropic, Gemini, Mistral, … tokenizers are not public / not bundled).
|
|
6
|
+
* Estimates are approximate; post-call metrics prefer real usage from the response.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Overhead per message / per reply, following OpenAI's published counting recipe.
|
|
10
|
+
const TOKENS_PER_MESSAGE = 3
|
|
11
|
+
const REPLY_PRIMING = 3
|
|
12
|
+
// Rough flat cost for a non-text content part (e.g. an image) when estimating.
|
|
13
|
+
const NON_TEXT_PART_TOKENS = 85
|
|
14
|
+
|
|
15
|
+
const FAMILIES = [
|
|
16
|
+
// order matters: more specific prefixes first
|
|
17
|
+
{ re: /^(gpt-4o|gpt-4\.1|gpt-4\.5|gpt-5|gpt-oss|o1|o3|o4|chatgpt-4o|gpt-realtime)/, encoding: 'o200k_base' },
|
|
18
|
+
{ re: /^(gpt-4|gpt-3\.5|text-embedding-3|text-embedding-ada)/, encoding: 'cl100k_base' }
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const encoders = new Map()
|
|
22
|
+
let tiktoken // lazy: the rank tables are large
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Strip vendor prefixes used by AI Core model names, e.g. "azure-openai--gpt-4.1" or "openai/gpt-4.1".
|
|
26
|
+
* @param {string} model
|
|
27
|
+
*/
|
|
28
|
+
const baseModelName = (model) => String(model ?? '').toLowerCase().replace(/^.*(--|\/)/, '')
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {string} [model]
|
|
32
|
+
* @returns {string|null} tiktoken encoding name, or null when the heuristic should be used
|
|
33
|
+
*/
|
|
34
|
+
function encodingFor (model) {
|
|
35
|
+
const name = baseModelName(model)
|
|
36
|
+
return FAMILIES.find(f => f.re.test(name))?.encoding ?? null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getEncoder (encoding) {
|
|
40
|
+
if (!encoders.has(encoding)) {
|
|
41
|
+
tiktoken ??= require('js-tiktoken')
|
|
42
|
+
encoders.set(encoding, tiktoken.getEncoding(encoding))
|
|
43
|
+
}
|
|
44
|
+
return encoders.get(encoding)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** @param {string} text */
|
|
48
|
+
const heuristic = (text) => Math.ceil(text.length / 4)
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Count tokens of a string.
|
|
52
|
+
* @param {string} text
|
|
53
|
+
* @param {string} [model]
|
|
54
|
+
* @returns {{ tokens: number, method: 'tiktoken'|'heuristic' }}
|
|
55
|
+
*/
|
|
56
|
+
function countText (text, model) {
|
|
57
|
+
const str = typeof text === 'string' ? text : String(text ?? '')
|
|
58
|
+
if (!str) return { tokens: 0, method: encodingFor(model) ? 'tiktoken' : 'heuristic' }
|
|
59
|
+
const encoding = encodingFor(model)
|
|
60
|
+
if (encoding) {
|
|
61
|
+
try {
|
|
62
|
+
return { tokens: getEncoder(encoding).encode(str).length, method: 'tiktoken' }
|
|
63
|
+
} catch { /* fall through to heuristic */ }
|
|
64
|
+
}
|
|
65
|
+
return { tokens: heuristic(str), method: 'heuristic' }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Flatten message content into text and count non-text parts.
|
|
70
|
+
* @param {any} content
|
|
71
|
+
* @returns {{ text: string, extraTokens: number }}
|
|
72
|
+
*/
|
|
73
|
+
function contentText (content) {
|
|
74
|
+
if (content == null) return { text: '', extraTokens: 0 }
|
|
75
|
+
if (typeof content === 'string') return { text: content, extraTokens: 0 }
|
|
76
|
+
if (Array.isArray(content)) {
|
|
77
|
+
let text = ''
|
|
78
|
+
let extraTokens = 0
|
|
79
|
+
for (const part of content) {
|
|
80
|
+
if (typeof part === 'string') text += part
|
|
81
|
+
else if (typeof part?.text === 'string') text += part.text
|
|
82
|
+
else extraTokens += NON_TEXT_PART_TOKENS
|
|
83
|
+
}
|
|
84
|
+
return { text, extraTokens }
|
|
85
|
+
}
|
|
86
|
+
return { text: JSON.stringify(content), extraTokens: 0 }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Tokens of one message, including the per-message overhead.
|
|
91
|
+
* @param {object} m
|
|
92
|
+
* @param {string} [model]
|
|
93
|
+
*/
|
|
94
|
+
function messageTokens (m, model) {
|
|
95
|
+
if (!m) return 0
|
|
96
|
+
const { text, extraTokens } = contentText(m.content)
|
|
97
|
+
const parts = [m.role ?? '', text]
|
|
98
|
+
if (m.name) parts.push(m.name)
|
|
99
|
+
if (m.tool_calls) parts.push(JSON.stringify(m.tool_calls))
|
|
100
|
+
if (m.tool_call_id) parts.push(m.tool_call_id)
|
|
101
|
+
return TOKENS_PER_MESSAGE + countText(parts.join('\n'), model).tokens + extraTokens
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Tokens of one tool definition (serialised JSON; providers use a similar but not
|
|
106
|
+
* identical internal format).
|
|
107
|
+
* @param {object} tool
|
|
108
|
+
* @param {string} [model]
|
|
109
|
+
*/
|
|
110
|
+
const toolTokens = (tool, model) => countText(JSON.stringify(tool ?? null), model).tokens
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Estimate the prompt tokens of a chat request.
|
|
114
|
+
* @param {Array<object>} messages
|
|
115
|
+
* @param {{ model?: string, tools?: Array<object> }} [opts]
|
|
116
|
+
* @returns {{ tokens: number, method: 'tiktoken'|'heuristic' }}
|
|
117
|
+
*/
|
|
118
|
+
function estimateMessages (messages, { model, tools } = {}) {
|
|
119
|
+
return createMemoEstimator(model).estimate(messages, tools)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Estimate tokens consumed by tool definitions.
|
|
124
|
+
* @param {Array<object>} tools
|
|
125
|
+
* @param {string} [model]
|
|
126
|
+
*/
|
|
127
|
+
function estimateTools (tools, model) {
|
|
128
|
+
const method = encodingFor(model) ? 'tiktoken' : 'heuristic'
|
|
129
|
+
if (!Array.isArray(tools) || !tools.length) return { tokens: 0, method }
|
|
130
|
+
return { tokens: tools.reduce((n, t) => n + toolTokens(t, model), 0), method }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Estimator that remembers the count of every message and tool object it has seen.
|
|
135
|
+
* Pipeline steps keep unchanged messages by identity, so re-estimating after each
|
|
136
|
+
* step only counts what the step changed.
|
|
137
|
+
* @param {string} [model]
|
|
138
|
+
*/
|
|
139
|
+
function createMemoEstimator (model) {
|
|
140
|
+
const method = encodingFor(model) ? 'tiktoken' : 'heuristic'
|
|
141
|
+
const memo = new WeakMap()
|
|
142
|
+
const cached = (obj, fn) => {
|
|
143
|
+
if (obj === null || typeof obj !== 'object') return fn(obj, model)
|
|
144
|
+
if (!memo.has(obj)) memo.set(obj, fn(obj, model))
|
|
145
|
+
return memo.get(obj)
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
/**
|
|
149
|
+
* @param {Array<object>} messages
|
|
150
|
+
* @param {Array<object>} [tools]
|
|
151
|
+
* @returns {{ tokens: number, method: 'tiktoken'|'heuristic' }}
|
|
152
|
+
*/
|
|
153
|
+
estimate (messages, tools) {
|
|
154
|
+
let tokens = 0
|
|
155
|
+
for (const m of messages ?? []) tokens += cached(m, messageTokens)
|
|
156
|
+
if (tokens > 0) tokens += REPLY_PRIMING
|
|
157
|
+
if (Array.isArray(tools)) for (const t of tools) tokens += cached(t, toolTokens)
|
|
158
|
+
return { tokens, method }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { countText, estimateMessages, estimateTools, createMemoEstimator, encodingFor, baseModelName }
|
package/lib/util/cds.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Access the application's @sap/cds instance.
|
|
4
|
+
* @sap/cds sets `global.cds`; preferring it avoids picking up a second copy from this
|
|
5
|
+
* package's own node_modules (common with `file:` or `npm link` installs).
|
|
6
|
+
* Returns null when CAP is not loaded — the optimizer also works outside CAP.
|
|
7
|
+
* @returns {any|null}
|
|
8
|
+
*/
|
|
9
|
+
function getCds () {
|
|
10
|
+
return globalThis.cds ?? null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
module.exports = { getCds }
|
package/lib/util/hash.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { createHash } = require('node:crypto')
|
|
3
|
+
|
|
4
|
+
/** @param {string} text */
|
|
5
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deterministic JSON serialisation (sorted object keys) so equal values hash equally.
|
|
9
|
+
* @param {any} value
|
|
10
|
+
* @returns {string}
|
|
11
|
+
*/
|
|
12
|
+
function stableStringify (value) {
|
|
13
|
+
if (value === undefined) return 'null'
|
|
14
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
15
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
|
|
16
|
+
const keys = Object.keys(value).filter(k => value[k] !== undefined).sort()
|
|
17
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Pseudonymise a user id. Returns null for empty input.
|
|
22
|
+
* @param {string|undefined|null} userId
|
|
23
|
+
* @param {string} [salt]
|
|
24
|
+
*/
|
|
25
|
+
function hashUser (userId, salt = '') {
|
|
26
|
+
if (!userId) return null
|
|
27
|
+
return sha256(`${salt}:${userId}`).slice(0, 16)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Hash + length fingerprint of a prompt — what gets logged instead of the raw text.
|
|
32
|
+
* @param {Array<object>} messages
|
|
33
|
+
* @returns {{ promptHash: string, promptLength: number }}
|
|
34
|
+
*/
|
|
35
|
+
function fingerprint (messages) {
|
|
36
|
+
const text = stableStringify(messages ?? [])
|
|
37
|
+
return { promptHash: sha256(text), promptLength: text.length }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { sha256, stableStringify, hashUser, fingerprint }
|
package/lib/util/log.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Logger: uses cds.log('token-optimizer') when CAP is loaded, else console.
|
|
4
|
+
* Resolved lazily on first use. Never throws.
|
|
5
|
+
*/
|
|
6
|
+
const { getCds } = require('./cds')
|
|
7
|
+
|
|
8
|
+
let _log
|
|
9
|
+
|
|
10
|
+
function getLogger () {
|
|
11
|
+
if (_log) return _log
|
|
12
|
+
const cds = getCds()
|
|
13
|
+
if (cds?.log) _log = cds.log('token-optimizer')
|
|
14
|
+
// the console fallback is not cached, so cds.log is picked up once CAP has loaded
|
|
15
|
+
return _log ?? fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const fallback = {
|
|
19
|
+
debug: () => {},
|
|
20
|
+
info: (...a) => console.info('[token-optimizer]', ...a),
|
|
21
|
+
warn: (...a) => console.warn('[token-optimizer]', ...a),
|
|
22
|
+
error: (...a) => console.error('[token-optimizer]', ...a)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @param {'debug'|'info'|'warn'|'error'} level */
|
|
26
|
+
const safe = (level) => (...args) => {
|
|
27
|
+
try { getLogger()[level](...args) } catch { /* logging must never break the caller */ }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = {
|
|
31
|
+
debug: safe('debug'),
|
|
32
|
+
info: safe('info'),
|
|
33
|
+
warn: safe('warn'),
|
|
34
|
+
error: safe('error'),
|
|
35
|
+
/** @internal test hook */
|
|
36
|
+
_set (logger) { _log = logger }
|
|
37
|
+
}
|
package/lib/util/peer.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const { getCds } = require('./cds')
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load an optional peer dependency (e.g. @sap-ai-sdk/orchestration).
|
|
6
|
+
* Resolves from the application root first so the app's copy is used even when this package
|
|
7
|
+
* is installed via a symlink (`file:` / `npm link`), then falls back to normal resolution.
|
|
8
|
+
* @param {string} name
|
|
9
|
+
* @param {string} [purpose] shown in the error message
|
|
10
|
+
*/
|
|
11
|
+
function loadPeer (name, purpose) {
|
|
12
|
+
const roots = [getCds()?.root, process.cwd()].filter(Boolean)
|
|
13
|
+
for (const root of roots) {
|
|
14
|
+
try {
|
|
15
|
+
return require(require.resolve(name, { paths: [root] }))
|
|
16
|
+
} catch (e) {
|
|
17
|
+
if (e?.code !== 'MODULE_NOT_FOUND') throw e
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return require(name)
|
|
22
|
+
} catch (e) {
|
|
23
|
+
if (e?.code !== 'MODULE_NOT_FOUND') throw e
|
|
24
|
+
const err = new Error(`${purpose ? `${purpose} requires` : 'Missing'} peer dependency "${name}". Install it with: npm i ${name}`)
|
|
25
|
+
err.code = 'PEER_DEPENDENCY_MISSING'
|
|
26
|
+
throw err
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Version of an installed peer, read from its package.json next to the resolved entry
|
|
32
|
+
* (works when package.json is not in the package's "exports"). Null if unknown.
|
|
33
|
+
* @param {string} name
|
|
34
|
+
*/
|
|
35
|
+
function peerVersion (name) {
|
|
36
|
+
const fs = require('node:fs')
|
|
37
|
+
const path = require('node:path')
|
|
38
|
+
const roots = [getCds()?.root, process.cwd()].filter(Boolean)
|
|
39
|
+
let entry = null
|
|
40
|
+
for (const root of roots) {
|
|
41
|
+
try { entry = require.resolve(name, { paths: [root] }); break } catch { /* next */ }
|
|
42
|
+
}
|
|
43
|
+
try { entry ??= require.resolve(name) } catch { return null }
|
|
44
|
+
for (let dir = path.dirname(entry); dir !== path.dirname(dir); dir = path.dirname(dir)) {
|
|
45
|
+
const file = path.join(dir, 'package.json')
|
|
46
|
+
if (!fs.existsSync(file)) continue
|
|
47
|
+
try {
|
|
48
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
49
|
+
if (pkg.name === name) return pkg.version ?? null
|
|
50
|
+
} catch { return null }
|
|
51
|
+
}
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { loadPeer, peerVersion }
|
package/package.json
CHANGED
|
@@ -1,6 +1,133 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viveka10/cap-ai-token-optimizer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reduce, measure and report SAP AI Core token usage in SAP CAP Node.js apps: prompt optimization, response cache, metrics for SAP Cloud ALM / OpenTelemetry, HTTP and HANA",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sap",
|
|
7
|
+
"cap",
|
|
8
|
+
"cds",
|
|
9
|
+
"cds-plugin",
|
|
10
|
+
"sap-ai-core",
|
|
11
|
+
"sap-ai-sdk",
|
|
12
|
+
"generative-ai",
|
|
13
|
+
"llm",
|
|
14
|
+
"tokens",
|
|
15
|
+
"token-optimization",
|
|
16
|
+
"prompt",
|
|
17
|
+
"cache",
|
|
18
|
+
"opentelemetry",
|
|
19
|
+
"cloud-alm",
|
|
20
|
+
"mcp",
|
|
21
|
+
"agents",
|
|
22
|
+
"langchain",
|
|
23
|
+
"btp"
|
|
24
|
+
],
|
|
25
|
+
"homepage": "https://github.com/viveka10/npm_token_optimizer/tree/main/cap-ai-token-optimizer#readme",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/viveka10/npm_token_optimizer/issues"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/viveka10/npm_token_optimizer.git",
|
|
32
|
+
"directory": "cap-ai-token-optimizer"
|
|
33
|
+
},
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"author": "Vivekanandhan Srinivasan",
|
|
36
|
+
"main": "index.js",
|
|
37
|
+
"types": "index.d.ts",
|
|
38
|
+
"exports": {
|
|
39
|
+
".": {
|
|
40
|
+
"types": "./index.d.ts",
|
|
41
|
+
"default": "./index.js"
|
|
42
|
+
},
|
|
43
|
+
"./cds-plugin": "./cds-plugin.js",
|
|
44
|
+
"./cds-plugin.js": "./cds-plugin.js",
|
|
45
|
+
"./db/*": "./db/*",
|
|
46
|
+
"./srv/*": "./srv/*",
|
|
47
|
+
"./package.json": "./package.json"
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"index.js",
|
|
51
|
+
"index.d.ts",
|
|
52
|
+
"cds-plugin.js",
|
|
53
|
+
"lib/",
|
|
54
|
+
"srv/",
|
|
55
|
+
"db/",
|
|
56
|
+
"docs/",
|
|
57
|
+
"CHANGELOG.md",
|
|
58
|
+
"SECURITY.md"
|
|
59
|
+
],
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=22.12"
|
|
62
|
+
},
|
|
63
|
+
"publishConfig": {
|
|
64
|
+
"access": "public"
|
|
65
|
+
},
|
|
66
|
+
"scripts": {
|
|
67
|
+
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage",
|
|
68
|
+
"test:unit": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/unit",
|
|
69
|
+
"test:integration": "node --experimental-vm-modules node_modules/jest/bin/jest.js test/integration",
|
|
70
|
+
"lint": "eslint .",
|
|
71
|
+
"mock:aicore": "node test/mock-aicore/server.js",
|
|
72
|
+
"bench": "node bench/run.js",
|
|
73
|
+
"test:live": "RUN_LIVE_TESTS=1 node --experimental-vm-modules node_modules/jest/bin/jest.js test/live",
|
|
74
|
+
"test:smoke": "node --test test/smoke/smoke.js",
|
|
75
|
+
"check:pack": "node scripts/check-pack.js",
|
|
76
|
+
"prepublishOnly": "npm run check:pack"
|
|
77
|
+
},
|
|
78
|
+
"cds": {
|
|
79
|
+
"requires": {
|
|
80
|
+
"tokenOptimizer": {
|
|
81
|
+
"kind": "token-optimizer"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
"dependencies": {
|
|
86
|
+
"js-tiktoken": "^1.0.21"
|
|
87
|
+
},
|
|
88
|
+
"peerDependencies": {
|
|
89
|
+
"@sap/cds": "^9 || ^10",
|
|
90
|
+
"@sap-ai-sdk/orchestration": "^2.8.0",
|
|
91
|
+
"@sap-ai-sdk/foundation-models": "^2.8.0",
|
|
92
|
+
"@sap-ai-sdk/langchain": "^2.16.0",
|
|
93
|
+
"@sap-cloud-sdk/connectivity": "^4",
|
|
94
|
+
"@opentelemetry/api": "^1.4"
|
|
95
|
+
},
|
|
96
|
+
"peerDependenciesMeta": {
|
|
97
|
+
"@sap/cds": {
|
|
98
|
+
"optional": true
|
|
99
|
+
},
|
|
100
|
+
"@sap-ai-sdk/orchestration": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
103
|
+
"@sap-ai-sdk/foundation-models": {
|
|
104
|
+
"optional": true
|
|
105
|
+
},
|
|
106
|
+
"@sap-ai-sdk/langchain": {
|
|
107
|
+
"optional": true
|
|
108
|
+
},
|
|
109
|
+
"@sap-cloud-sdk/connectivity": {
|
|
110
|
+
"optional": true
|
|
111
|
+
},
|
|
112
|
+
"@opentelemetry/api": {
|
|
113
|
+
"optional": true
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"devDependencies": {
|
|
117
|
+
"@eslint/js": "^9.39.5",
|
|
118
|
+
"@langchain/core": "^1.2.12",
|
|
119
|
+
"@opentelemetry/api": "^1.9.0",
|
|
120
|
+
"@opentelemetry/context-async-hooks": "^2.11.0",
|
|
121
|
+
"@opentelemetry/sdk-metrics": "^2.11.0",
|
|
122
|
+
"@opentelemetry/sdk-trace-base": "^2.11.0",
|
|
123
|
+
"@sap-ai-sdk/foundation-models": "^2.16.0",
|
|
124
|
+
"@sap-ai-sdk/langchain": "^2.16.0",
|
|
125
|
+
"@sap-ai-sdk/orchestration": "^2.16.0",
|
|
126
|
+
"@sap/cds": "^10.1.1",
|
|
127
|
+
"eslint": "^9.39.5",
|
|
128
|
+
"express": "^5.3.0",
|
|
129
|
+
"globals": "^17.13.0",
|
|
130
|
+
"jest": "^30.5.2",
|
|
131
|
+
"langchain": "^1.5.12"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only statistics of @viveka10/cap-ai-token-optimizer for this app instance (in-memory, since
|
|
3
|
+
* start or the last reset). Served at /odata/v4/token-optimizer when
|
|
4
|
+
* cds.requires.tokenOptimizer.admin.enabled is true. The required role is
|
|
5
|
+
* cds.requires.tokenOptimizer.admin.role (default TokenOptimizerAdmin).
|
|
6
|
+
*/
|
|
7
|
+
@path : 'token-optimizer'
|
|
8
|
+
@requires: 'TokenOptimizerAdmin'
|
|
9
|
+
@readonly
|
|
10
|
+
service TokenOptimizerService {
|
|
11
|
+
|
|
12
|
+
type Tokens {
|
|
13
|
+
prompt : Integer64;
|
|
14
|
+
completion : Integer64;
|
|
15
|
+
total : Integer64;
|
|
16
|
+
cached : Integer64;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type Latency {
|
|
20
|
+
count : Integer;
|
|
21
|
+
avg : Integer;
|
|
22
|
+
p50 : Integer;
|
|
23
|
+
p95 : Integer;
|
|
24
|
+
max : Integer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
aspect Totals {
|
|
28
|
+
calls : Integer64;
|
|
29
|
+
success : Integer64;
|
|
30
|
+
errors : Integer64;
|
|
31
|
+
cacheHits : Integer64;
|
|
32
|
+
tokens : Tokens;
|
|
33
|
+
savedTokensEstimate : Integer64;
|
|
34
|
+
costEstimate : Decimal(20, 6);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Summary : Totals {
|
|
38
|
+
since : Timestamp;
|
|
39
|
+
mode : String;
|
|
40
|
+
latencyMs : Latency;
|
|
41
|
+
listenerErrors : Integer;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Totals for this app instance. */
|
|
45
|
+
function summary() returns Summary;
|
|
46
|
+
|
|
47
|
+
@cds.persistence.skip
|
|
48
|
+
entity UseCases : Totals {
|
|
49
|
+
key name : String;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@cds.persistence.skip
|
|
53
|
+
entity Models : Totals {
|
|
54
|
+
key name : String;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@cds.persistence.skip
|
|
58
|
+
entity ErrorCategories {
|
|
59
|
+
key category : String;
|
|
60
|
+
count : Integer64;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@cds.persistence.skip
|
|
64
|
+
entity StepSavings {
|
|
65
|
+
key step : String;
|
|
66
|
+
tokens : Integer64;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Prompt-caching advisory: calls whose system prompt or tools changed, per use case. */
|
|
70
|
+
@cds.persistence.skip
|
|
71
|
+
entity PromptPrefixChanges {
|
|
72
|
+
key useCase : String;
|
|
73
|
+
count : Integer64;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@cds.persistence.skip
|
|
77
|
+
entity Exporters {
|
|
78
|
+
key name : String;
|
|
79
|
+
errors : Integer64;
|
|
80
|
+
/** JSON from the exporter's stats(), e.g. queued, sent, dropped for http */
|
|
81
|
+
stats : LargeString;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Use the app's @sap/cds instance (see lib/util/cds.js)
|
|
3
|
+
const cds = globalThis.cds ?? require('@sap/cds')
|
|
4
|
+
const { optimizer } = require('../index')
|
|
5
|
+
|
|
6
|
+
const totals = (t) => ({
|
|
7
|
+
calls: t.calls,
|
|
8
|
+
success: t.success,
|
|
9
|
+
errors: t.errors,
|
|
10
|
+
cacheHits: t.cacheHits,
|
|
11
|
+
tokens: { ...t.tokens },
|
|
12
|
+
savedTokensEstimate: t.savedTokensEstimate,
|
|
13
|
+
costEstimate: t.costEstimate
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
/** Rows for a READ, honouring a key lookup (e.g. UseCases('ask')). */
|
|
17
|
+
function rows (req, list, key) {
|
|
18
|
+
const wanted = req.data?.[key]
|
|
19
|
+
if (wanted === undefined) return list
|
|
20
|
+
return list.find(r => r[key] === wanted) ?? null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = class TokenOptimizerService extends cds.ApplicationService {
|
|
24
|
+
init () {
|
|
25
|
+
this.on('summary', () => {
|
|
26
|
+
const s = optimizer.stats()
|
|
27
|
+
return { ...totals(s), since: s.since, mode: optimizer.config.mode, latencyMs: s.latencyMs, listenerErrors: s.listenerErrors }
|
|
28
|
+
})
|
|
29
|
+
this.on('READ', 'UseCases', (req) => rows(req, Object.entries(optimizer.stats().byUseCase).map(([name, t]) => ({ name, ...totals(t) })), 'name'))
|
|
30
|
+
this.on('READ', 'Models', (req) => rows(req, Object.entries(optimizer.stats().byModel).map(([name, t]) => ({ name, ...totals(t) })), 'name'))
|
|
31
|
+
this.on('READ', 'ErrorCategories', (req) => rows(req, Object.entries(optimizer.stats().errorsByCategory).map(([category, count]) => ({ category, count })), 'category'))
|
|
32
|
+
this.on('READ', 'StepSavings', (req) => rows(req, Object.entries(optimizer.stats().savingsByStep).map(([step, tokens]) => ({ step, tokens })), 'step'))
|
|
33
|
+
this.on('READ', 'PromptPrefixChanges', (req) => rows(req, Object.entries(optimizer.stats().promptPrefixChanges).map(([useCase, count]) => ({ useCase, count })), 'useCase'))
|
|
34
|
+
this.on('READ', 'Exporters', (req) => {
|
|
35
|
+
const s = optimizer.stats()
|
|
36
|
+
const names = optimizer.collector.exporters.map(x => x.name ?? 'anonymous')
|
|
37
|
+
return rows(req, names.map(name => ({ name, errors: s.exporterErrors[name] ?? 0, stats: s.exporters[name] ? JSON.stringify(s.exporters[name]) : null })), 'name')
|
|
38
|
+
})
|
|
39
|
+
return super.init()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
using { cap.ai.token_optimizer as db } from '../db/token-usage-log';
|
|
2
|
+
using from './admin-service';
|
|
3
|
+
|
|
4
|
+
/** Added to the admin service when both the admin service and the db exporter are enabled. */
|
|
5
|
+
extend service TokenOptimizerService with {
|
|
6
|
+
@readonly
|
|
7
|
+
entity UsageLog as projection on db.TokenUsageLog;
|
|
8
|
+
}
|