copilot-usage-studio 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/lib/cli.mjs ADDED
@@ -0,0 +1,173 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir, platform } from 'node:os';
3
+ import { dirname, join, posix, resolve, win32 } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ import { createLocalRuntime } from './local-runtime.mjs';
7
+ import { scanVsCodeSessions, writeSessionData } from './scanner-api.mjs';
8
+
9
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
+ const packageJson = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'));
11
+
12
+ export function defaultAppDataDir(environment = process.env, os = platform(), home = homedir()) {
13
+ if (os === 'win32') {
14
+ return win32.join(
15
+ environment.LOCALAPPDATA ?? win32.join(home, 'AppData', 'Local'),
16
+ 'Copilot Usage Studio',
17
+ );
18
+ }
19
+ if (os === 'darwin') {
20
+ return posix.join(home, 'Library', 'Application Support', 'Copilot Usage Studio');
21
+ }
22
+ return posix.join(
23
+ environment.XDG_DATA_HOME ?? posix.join(home, '.local', 'share'),
24
+ 'copilot-usage-studio',
25
+ );
26
+ }
27
+
28
+ export function parseCliArgs(args = process.argv.slice(2)) {
29
+ const command = args[0] && !args[0].startsWith('-') ? args[0] : 'serve';
30
+ const commandArgs = command === 'serve' && command !== args[0] ? args : args.slice(1);
31
+ const options = {
32
+ command,
33
+ host: process.env.COPILOT_USAGE_STUDIO_HOST ?? '127.0.0.1',
34
+ port: Number(process.env.COPILOT_USAGE_STUDIO_PORT ?? 4312),
35
+ output: '',
36
+ roots: [],
37
+ scanOnStart: true,
38
+ };
39
+
40
+ if (command === 'help' || command === 'version') {
41
+ return options;
42
+ }
43
+ if (!['serve', 'scan', 'status'].includes(command)) {
44
+ throw new Error(`Unknown command: ${command}`);
45
+ }
46
+
47
+ for (let index = 0; index < commandArgs.length; index += 1) {
48
+ const argument = commandArgs[index];
49
+ if (argument === '--help' || argument === '-h') {
50
+ options.command = 'help';
51
+ continue;
52
+ }
53
+ if (argument === '--version' || argument === '-v') {
54
+ options.command = 'version';
55
+ continue;
56
+ }
57
+ if (argument === '--no-startup-scan') {
58
+ options.scanOnStart = false;
59
+ continue;
60
+ }
61
+
62
+ const [flag, inlineValue] = argument.split('=', 2);
63
+ const value = inlineValue ?? commandArgs[index + 1];
64
+ if (['--host', '--port', '--output', '--root'].includes(flag) && !value) {
65
+ throw new Error(`${flag} requires a value.`);
66
+ }
67
+ if (['--host', '--port', '--output', '--root'].includes(flag) && inlineValue === undefined) {
68
+ index += 1;
69
+ }
70
+
71
+ if (flag === '--host') options.host = value;
72
+ else if (flag === '--port') options.port = Number(value);
73
+ else if (flag === '--output') options.output = value;
74
+ else if (flag === '--root') options.roots.push(value);
75
+ else if (!['--no-startup-scan', '--help', '-h', '--version', '-v'].includes(flag)) {
76
+ throw new Error(`Unknown option: ${argument}`);
77
+ }
78
+ }
79
+
80
+ if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65_535) {
81
+ throw new Error('--port must be an integer between 0 and 65535.');
82
+ }
83
+ return options;
84
+ }
85
+
86
+ export async function runCli(args = process.argv.slice(2), dependencies = {}) {
87
+ const options = parseCliArgs(args);
88
+ const logger = dependencies.logger ?? console;
89
+ const appDataDir = dependencies.appDataDir ?? defaultAppDataDir();
90
+
91
+ if (options.command === 'help') {
92
+ logger.log(helpText());
93
+ return null;
94
+ }
95
+ if (options.command === 'version') {
96
+ logger.log(packageJson.version);
97
+ return null;
98
+ }
99
+ if (options.command === 'status') {
100
+ const fetchStatus = dependencies.fetch ?? globalThis.fetch;
101
+ const response = await fetchStatus(`http://${options.host}:${options.port}/api/status`);
102
+ if (!response.ok) {
103
+ throw new Error(`Local runtime returned ${response.status}.`);
104
+ }
105
+ const status = await response.json();
106
+ logger.log(JSON.stringify(status, null, 2));
107
+ return status;
108
+ }
109
+
110
+ const scanOptions = options.roots.length ? { roots: options.roots } : {};
111
+ if (options.command === 'scan') {
112
+ const scanner = dependencies.scanner ?? scanVsCodeSessions;
113
+ const writer = dependencies.writer ?? writeSessionData;
114
+ const sessionData = await scanner(scanOptions);
115
+ const outputFile = resolve(options.output || join(appDataDir, 'sessions.json'));
116
+ writer(sessionData, outputFile);
117
+ logger.log(`Imported ${sessionData.sessions.length} sessions to ${outputFile}`);
118
+ return sessionData;
119
+ }
120
+
121
+ const staticDir = dependencies.staticDir ?? join(packageRoot, 'dist', 'copilot-usage-studio', 'browser');
122
+ const hasStaticAssets = dependencies.hasStaticAssets ?? existsSync;
123
+ if (!hasStaticAssets(join(staticDir, 'index.html'))) {
124
+ throw new Error('Packaged UI assets are missing. Run npm run build before serving from this checkout.');
125
+ }
126
+
127
+ const runtimeFactory = dependencies.runtimeFactory ?? createLocalRuntime;
128
+ const runtime = runtimeFactory({
129
+ host: options.host,
130
+ port: options.port,
131
+ dataFile: join(appDataDir, 'sessions.json'),
132
+ seedDataFile: null,
133
+ staticDir,
134
+ scanOnStart: options.scanOnStart,
135
+ scanOptions,
136
+ logger,
137
+ });
138
+ await runtime.listen();
139
+ installShutdownHandlers(runtime, dependencies.processObject ?? process);
140
+ return runtime;
141
+ }
142
+
143
+ export function helpText() {
144
+ return `Copilot Usage Studio ${packageJson.version}
145
+
146
+ Usage:
147
+ copilot-usage-studio [serve] [options]
148
+ copilot-usage-studio scan [options]
149
+ copilot-usage-studio status [options]
150
+
151
+ Commands:
152
+ serve Scan local VS Code data and serve the local UI (default)
153
+ scan Scan and write the normalized session-data JSON
154
+ status Read status from a running local runtime
155
+
156
+ Options:
157
+ --host <host> Bind host (default: 127.0.0.1)
158
+ --port <port> Runtime port (default: 4312)
159
+ --root <path> Custom VS Code User or workspace-storage path; repeatable
160
+ --output <file> Output file for the scan command
161
+ --no-startup-scan Serve the last local cache without scanning on startup
162
+ -h, --help Show help
163
+ -v, --version Show version`;
164
+ }
165
+
166
+ function installShutdownHandlers(runtime, processObject) {
167
+ const close = async () => {
168
+ await runtime.close();
169
+ processObject.exit(0);
170
+ };
171
+ processObject.once('SIGINT', close);
172
+ processObject.once('SIGTERM', close);
173
+ }
@@ -0,0 +1,231 @@
1
+ import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs';
2
+ import { createServer } from 'node:http';
3
+ import { extname, join, normalize, resolve, sep } from 'node:path';
4
+
5
+ import { scanVsCodeSessions, writeSessionData } from './scanner-api.mjs';
6
+
7
+ const contentTypes = new Map([
8
+ ['.css', 'text/css; charset=utf-8'],
9
+ ['.html', 'text/html; charset=utf-8'],
10
+ ['.ico', 'image/x-icon'],
11
+ ['.js', 'text/javascript; charset=utf-8'],
12
+ ['.json', 'application/json; charset=utf-8'],
13
+ ['.map', 'application/json; charset=utf-8'],
14
+ ['.png', 'image/png'],
15
+ ['.svg', 'image/svg+xml'],
16
+ ['.webp', 'image/webp'],
17
+ ['.woff', 'font/woff'],
18
+ ['.woff2', 'font/woff2'],
19
+ ]);
20
+
21
+ export function createLocalRuntime(options = {}) {
22
+ const host = options.host ?? '127.0.0.1';
23
+ const port = Number(options.port ?? 4312);
24
+ const dataFile = resolve(options.dataFile ?? 'tmp/local-runtime/sessions.json');
25
+ const seedDataFile = options.seedDataFile === null
26
+ ? null
27
+ : resolve(options.seedDataFile ?? 'public/data/sessions.json');
28
+ const staticDir = resolve(options.staticDir ?? 'dist/copilot-usage-studio/browser');
29
+ const scanOptions = options.scanOptions ?? {};
30
+ const scanner = options.scanner ?? scanVsCodeSessions;
31
+ const writer = options.writer ?? writeSessionData;
32
+ const logger = options.logger ?? console;
33
+
34
+ let sessionData = readCachedSessionData(dataFile, logger)
35
+ ?? (seedDataFile ? readCachedSessionData(seedDataFile, logger) : null);
36
+ let phase = sessionData ? 'ready' : 'empty';
37
+ let lastError = '';
38
+ let lastScanStartedAt = '';
39
+ let lastScanCompletedAt = '';
40
+ let lastScanDurationMs = 0;
41
+ let activeScan = null;
42
+
43
+ function status() {
44
+ return {
45
+ phase,
46
+ scanning: Boolean(activeScan),
47
+ hasData: Boolean(sessionData),
48
+ sessionCount: sessionData?.sessions?.length ?? 0,
49
+ generatedAt: sessionData?.generatedAt ?? '',
50
+ lastScanStartedAt,
51
+ lastScanCompletedAt,
52
+ lastScanDurationMs,
53
+ lastError,
54
+ };
55
+ }
56
+
57
+ function refresh(reason = 'manual') {
58
+ if (activeScan) {
59
+ return activeScan;
60
+ }
61
+
62
+ const started = Date.now();
63
+ lastScanStartedAt = new Date(started).toISOString();
64
+ lastError = '';
65
+ phase = 'scanning';
66
+
67
+ activeScan = Promise.resolve()
68
+ .then(() => scanner(scanOptions))
69
+ .then((nextSessionData) => {
70
+ writer(nextSessionData, dataFile);
71
+ sessionData = nextSessionData;
72
+ phase = 'ready';
73
+ lastScanCompletedAt = new Date().toISOString();
74
+ lastScanDurationMs = Date.now() - started;
75
+ logger.log(
76
+ `Local data refresh (${reason}) imported ${nextSessionData.sessions.length} sessions in ${lastScanDurationMs}ms.`,
77
+ );
78
+ return nextSessionData;
79
+ })
80
+ .catch((error) => {
81
+ lastError = error instanceof Error ? error.message : String(error);
82
+ phase = sessionData ? 'ready' : 'error';
83
+ lastScanCompletedAt = new Date().toISOString();
84
+ lastScanDurationMs = Date.now() - started;
85
+ logger.error(`Local data refresh failed: ${lastError}`);
86
+ throw error;
87
+ })
88
+ .finally(() => {
89
+ activeScan = null;
90
+ });
91
+
92
+ return activeScan;
93
+ }
94
+
95
+ const server = createServer(async (request, response) => {
96
+ try {
97
+ const url = new URL(request.url ?? '/', `http://${request.headers.host ?? `${host}:${port}`}`);
98
+
99
+ if (request.method === 'GET' && url.pathname === '/api/status') {
100
+ return sendJson(response, 200, status());
101
+ }
102
+
103
+ if (request.method === 'GET' && url.pathname === '/api/sessions') {
104
+ await waitForFirstData();
105
+ return sessionData
106
+ ? sendJson(response, 200, sessionData)
107
+ : sendJson(response, 503, { error: 'No session data is available yet.', status: status() });
108
+ }
109
+
110
+ if (request.method === 'GET' && url.pathname === '/data/sessions.json') {
111
+ await waitForFirstData();
112
+ return sessionData
113
+ ? sendJson(response, 200, sessionData)
114
+ : sendJson(response, 503, { error: 'No session data is available yet.', status: status() });
115
+ }
116
+
117
+ if (request.method === 'POST' && url.pathname === '/api/scan') {
118
+ try {
119
+ const nextSessionData = await refresh('manual');
120
+ return sendJson(response, 200, { sessionData: nextSessionData, status: status() });
121
+ } catch {
122
+ return sendJson(response, 500, { error: lastError, status: status() });
123
+ }
124
+ }
125
+
126
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
127
+ return sendJson(response, 405, { error: 'Method not allowed.' });
128
+ }
129
+
130
+ return serveStaticFile(request, response, staticDir, url.pathname);
131
+ } catch (error) {
132
+ logger.error(error);
133
+ return sendJson(response, 500, { error: 'Local runtime request failed.' });
134
+ }
135
+ });
136
+
137
+ return {
138
+ address: () => server.address(),
139
+ close: () => new Promise((resolveClose, rejectClose) => {
140
+ server.close((error) => (error ? rejectClose(error) : resolveClose()));
141
+ }),
142
+ listen: () => new Promise((resolveListen, rejectListen) => {
143
+ server.once('error', rejectListen);
144
+ server.listen(port, host, () => {
145
+ server.off('error', rejectListen);
146
+ const address = server.address();
147
+ logger.log(`Copilot Usage Studio local runtime: http://${host}:${address.port}/`);
148
+ resolveListen(address);
149
+
150
+ if (options.scanOnStart !== false) {
151
+ void refresh('startup').catch(() => {});
152
+ }
153
+ });
154
+ }),
155
+ refresh,
156
+ status,
157
+ };
158
+
159
+ async function waitForFirstData() {
160
+ if (!sessionData && activeScan) {
161
+ try {
162
+ await activeScan;
163
+ } catch {
164
+ // The response below reports the retained empty/error state.
165
+ }
166
+ }
167
+ }
168
+ }
169
+
170
+ function readCachedSessionData(dataFile, logger) {
171
+ if (!existsSync(dataFile)) {
172
+ return null;
173
+ }
174
+
175
+ try {
176
+ const value = JSON.parse(readFileSync(dataFile, 'utf8'));
177
+ return value && Array.isArray(value.sessions) ? value : null;
178
+ } catch (error) {
179
+ logger.warn(`Ignoring unreadable cached session data at ${dataFile}: ${error.message}`);
180
+ return null;
181
+ }
182
+ }
183
+
184
+ function sendJson(response, statusCode, body) {
185
+ const payload = JSON.stringify(body);
186
+ response.writeHead(statusCode, {
187
+ 'cache-control': 'no-store',
188
+ 'content-length': Buffer.byteLength(payload),
189
+ 'content-type': 'application/json; charset=utf-8',
190
+ });
191
+ response.end(payload);
192
+ }
193
+
194
+ function serveStaticFile(request, response, staticDir, pathname) {
195
+ if (!existsSync(staticDir)) {
196
+ return sendJson(response, 404, {
197
+ error: 'The production UI has not been built. Run npm run build first.',
198
+ });
199
+ }
200
+
201
+ const decodedPath = decodeURIComponent(pathname);
202
+ const relativePath = decodedPath === '/' ? 'index.html' : decodedPath.replace(/^\/+/, '');
203
+ let file = safeStaticPath(staticDir, relativePath);
204
+
205
+ if (!file || !existsSync(file) || !statSync(file).isFile()) {
206
+ file = safeStaticPath(staticDir, 'index.html');
207
+ }
208
+
209
+ if (!file || !existsSync(file)) {
210
+ return sendJson(response, 404, { error: 'UI file not found.' });
211
+ }
212
+
213
+ const headers = {
214
+ 'content-type': contentTypes.get(extname(file).toLowerCase()) ?? 'application/octet-stream',
215
+ };
216
+ if (file.endsWith('index.html')) {
217
+ headers['cache-control'] = 'no-store';
218
+ }
219
+ response.writeHead(200, headers);
220
+ if (request.method === 'HEAD') {
221
+ response.end();
222
+ return;
223
+ }
224
+ createReadStream(file).pipe(response);
225
+ }
226
+
227
+ function safeStaticPath(staticDir, relativePath) {
228
+ const root = `${resolve(staticDir)}${sep}`;
229
+ const candidate = resolve(join(staticDir, normalize(relativePath)));
230
+ return candidate.startsWith(root) ? candidate : null;
231
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Stable Node-side scanner boundary for local hosts.
3
+ *
4
+ * Hosts should consume this module instead of importing parser internals from
5
+ * scripts/scan-vscode-sessions.mjs. The returned object is the same normalized
6
+ * SessionData document consumed by the Angular application.
7
+ */
8
+ export {
9
+ defaultCodeUserDirs,
10
+ scanVsCodeSessions,
11
+ writeSessionData,
12
+ } from '../scripts/scan-vscode-sessions.mjs';
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "copilot-usage-studio",
3
+ "version": "0.1.0",
4
+ "description": "Local VS Code GitHub Copilot usage, cost, and session insights.",
5
+ "type": "module",
6
+ "bin": {
7
+ "copilot-usage-studio": "bin/copilot-usage-studio.mjs"
8
+ },
9
+ "files": [
10
+ "CHANGELOG.md",
11
+ "bin/",
12
+ "data/github-copilot-pricing.json",
13
+ "dist/copilot-usage-studio/browser/",
14
+ "docs/local-deployment.md",
15
+ "docs/pricing.md",
16
+ "docs/scanner-api.md",
17
+ "lib/",
18
+ "scripts/pricing-utils.mjs",
19
+ "scripts/scan-vscode-sessions.mjs"
20
+ ],
21
+ "scripts": {
22
+ "ng": "ng",
23
+ "start": "node scripts/start-local-dev.mjs",
24
+ "start:angular": "ng serve --proxy-config proxy.conf.json",
25
+ "start:clean": "npm run clean:ng-cache && node scripts/start-local-dev.mjs",
26
+ "runtime": "node scripts/local-runtime.mjs",
27
+ "preview:local": "npm run build && node scripts/local-runtime.mjs",
28
+ "cli": "node bin/copilot-usage-studio.mjs",
29
+ "prebuild": "node scripts/clean-dist.mjs",
30
+ "build": "ng build",
31
+ "watch": "ng build --watch --configuration development",
32
+ "test": "ng test",
33
+ "test:scripts": "node --test scripts/*.test.mjs",
34
+ "clean:ng-cache": "ng cache clean",
35
+ "refresh:data": "npm run scan && npm run verify:data",
36
+ "scan": "node scripts/scan-vscode-sessions.mjs",
37
+ "verify:data": "node scripts/verify-session-data.mjs",
38
+ "schema:audit": "node scripts/audit-vscode-schema.mjs",
39
+ "schema:accept": "node scripts/audit-vscode-schema.mjs --accept",
40
+ "verify:package": "node scripts/verify-package.mjs",
41
+ "release:check": "npm run test:scripts && npm test -- --watch=false && npm run build && npm run verify:package",
42
+ "prepack": "npm run build"
43
+ },
44
+ "exports": {
45
+ ".": "./lib/cli.mjs",
46
+ "./scanner": "./lib/scanner-api.mjs",
47
+ "./local-runtime": "./lib/local-runtime.mjs"
48
+ },
49
+ "engines": {
50
+ "node": ">=22.5.0"
51
+ },
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/fortunes-guardian/copilot-usage-studio.git"
55
+ },
56
+ "bugs": {
57
+ "url": "https://github.com/fortunes-guardian/copilot-usage-studio/issues"
58
+ },
59
+ "homepage": "https://github.com/fortunes-guardian/copilot-usage-studio#readme",
60
+ "keywords": [
61
+ "github-copilot",
62
+ "vscode",
63
+ "ai-usage",
64
+ "token-cost",
65
+ "developer-tools"
66
+ ],
67
+ "license": "MIT",
68
+ "packageManager": "npm@11.11.0",
69
+ "devDependencies": {
70
+ "@angular/build": "^21.2.9",
71
+ "@angular/cli": "^21.2.9",
72
+ "@angular/common": "^21.2.0",
73
+ "@angular/compiler": "^21.2.0",
74
+ "@angular/compiler-cli": "^21.2.0",
75
+ "@angular/core": "^21.2.0",
76
+ "@angular/forms": "^21.2.0",
77
+ "@angular/platform-browser": "^21.2.0",
78
+ "@angular/router": "^21.2.0",
79
+ "jsdom": "^28.0.0",
80
+ "prettier": "^3.8.1",
81
+ "rxjs": "~7.8.0",
82
+ "tslib": "^2.3.0",
83
+ "typescript": "~5.9.2",
84
+ "vitest": "^4.0.8"
85
+ }
86
+ }
@@ -0,0 +1,71 @@
1
+ export function modelKey(model) {
2
+ return String(model ?? '')
3
+ .replace(/^copilot\//i, '')
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, ' ')
6
+ .trim()
7
+ .replace(/\s+/g, ' ');
8
+ }
9
+
10
+ export function normalizeModel(model, pricing) {
11
+ const raw = String(model ?? '')
12
+ .replace(/^copilot\//i, '')
13
+ .trim();
14
+ const key = modelKey(raw);
15
+ const knownModels = Object.keys(pricing);
16
+
17
+ return (
18
+ knownModels.find((name) => modelKey(name) === key) ??
19
+ knownModels.find((name) => key.includes(modelKey(name))) ??
20
+ (raw || 'Unknown model')
21
+ );
22
+ }
23
+
24
+ export function pricingModelForModel(model, pricing, fallbackPricingModel) {
25
+ const normalized = normalizeModel(model, pricing);
26
+
27
+ return pricing[normalized] ? normalized : fallbackPricingModel;
28
+ }
29
+
30
+ export function priceForPricingModel(pricingModel, pricing, fallbackPricingModel) {
31
+ return pricing[pricingModel || ''] ?? pricing[fallbackPricingModel];
32
+ }
33
+
34
+ export function priceForTokens(pricingModel, tokens, pricing, fallbackPricingModel) {
35
+ const basePrice = priceForPricingModel(pricingModel, pricing, fallbackPricingModel);
36
+ const rawInputTokens =
37
+ Math.max(0, Number(tokens?.input ?? 0)) + Math.max(0, Number(tokens?.cachedInput ?? 0));
38
+ const tier = [...(basePrice.tiers ?? [])]
39
+ .sort((a, b) => b.thresholdInputTokensExclusive - a.thresholdInputTokensExclusive)
40
+ .find((candidate) => rawInputTokens > candidate.thresholdInputTokensExclusive);
41
+
42
+ return tier ? { ...basePrice, ...tier, tiers: basePrice.tiers } : basePrice;
43
+ }
44
+
45
+ export function costBreakdownUsdForTokens(pricingModel, tokens, pricing, fallbackPricingModel) {
46
+ const price = priceForTokens(pricingModel, tokens, pricing, fallbackPricingModel);
47
+ const input = (tokens.input / 1_000_000) * price.input;
48
+ const cachedInput = (tokens.cachedInput / 1_000_000) * price.cachedInput;
49
+ const cacheWrite = (tokens.cacheWrite / 1_000_000) * (price.cacheWrite ?? 0);
50
+ const output = (tokens.output / 1_000_000) * price.output;
51
+
52
+ return {
53
+ input,
54
+ cachedInput,
55
+ cacheWrite,
56
+ output,
57
+ total: input + cachedInput + cacheWrite + output,
58
+ tier: price.label ?? price.tierLabel ?? 'Default',
59
+ };
60
+ }
61
+
62
+ export function modelUsesPricingFallback(model, pricingModel, pricing, fallbackPricingModel) {
63
+ const normalized = normalizeModel(model, pricing);
64
+ const priceRow = pricingModel || pricingModelForModel(normalized, pricing, fallbackPricingModel);
65
+
66
+ return priceRow !== normalized || !pricing[normalized];
67
+ }
68
+
69
+ export function costUsdForTokens(pricingModel, tokens, pricing, fallbackPricingModel) {
70
+ return costBreakdownUsdForTokens(pricingModel, tokens, pricing, fallbackPricingModel).total;
71
+ }