@meller/tokentalos 1.0.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/api/index.js ADDED
@@ -0,0 +1,111 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import chalk from 'chalk';
6
+ import { initDb } from '../lib/engine/db.js';
7
+ import usageRouter, { setConfig as setUsageConfig } from './api/v1/usage.js';
8
+ import analyticsRouter from './api/v1/analytics.js';
9
+ import opvRouter, { setConfig as setOpvConfig } from './api/v1/opv.js';
10
+ import net from 'net';
11
+
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = path.dirname(__filename);
14
+
15
+ async function isPortInUse(port) {
16
+ return new Promise((resolve) => {
17
+ const server = net.createServer()
18
+ .once('error', (err) => {
19
+ if (err.code === 'EADDRINUSE') resolve(true);
20
+ else resolve(false);
21
+ })
22
+ .once('listening', () => {
23
+ server.close();
24
+ resolve(false);
25
+ })
26
+ .listen(port, '0.0.0.0');
27
+ });
28
+ }
29
+
30
+ export async function startServer(config) {
31
+ await initDb(config);
32
+ setUsageConfig(config);
33
+ setOpvConfig(config);
34
+ const app = express();
35
+ return createApp(app, config);
36
+ }
37
+
38
+ export function createApp(app, config) {
39
+ const isCollectorOnly = config._service === 'collector';
40
+ const isDashboardOnly = config._service === 'dashboard';
41
+
42
+ const PORT = isDashboardOnly ? (config.dashboardPort || 8060) : (config.gatewayPort || 8060);
43
+ const publicPath = path.join(__dirname, 'public');
44
+
45
+ // Store config for middleware access
46
+ app.set('tokentalosConfig', config);
47
+
48
+ app.use(cors());
49
+ app.use(express.json());
50
+
51
+ // --- API Router ---
52
+ // The Dashboard ALWAYS needs the Analytics and Recent Usage APIs to function.
53
+ if (config.enableCollector !== false || config.enableDashboard !== false) {
54
+ const apiRouter = express.Router();
55
+
56
+ // Shared / Analytics Routes (Needed by Dashboard)
57
+ apiRouter.use('/v1/analytics', analyticsRouter);
58
+
59
+ // Usage Router has both Read (recent) and Write (ingest/execute).
60
+ // We mount it for both, as the dashboard needs the 'recent' logs.
61
+ apiRouter.use('/v1/usage', usageRouter);
62
+ apiRouter.use('/v1/opv', opvRouter);
63
+
64
+ apiRouter.get('/', (req, res) => {
65
+ res.json({
66
+ name: isDashboardOnly ? 'TokenTalos Dashboard API' : 'TokenTalos Collector API',
67
+ version: '0.1.0',
68
+ services: {
69
+ collector: !isDashboardOnly && (config.enableCollector !== false),
70
+ dashboard: !isCollectorOnly && (config.enableDashboard !== false)
71
+ }
72
+ });
73
+ });
74
+
75
+ apiRouter.get('/health', (req, res) => {
76
+ res.json({ status: 'ok', service: isDashboardOnly ? 'dashboard' : 'collector' });
77
+ });
78
+
79
+ app.use('/api', apiRouter);
80
+ }
81
+
82
+ // --- Dashboard Static Assets ---
83
+ if (config.enableDashboard !== false && !isCollectorOnly) {
84
+ app.use(express.static(publicPath));
85
+
86
+ // Catch-all for SPA (excluding /api)
87
+ app.use((req, res, next) => {
88
+ if (req.path.startsWith('/api')) return next();
89
+ res.sendFile(path.join(publicPath, 'index.html'), (err) => {
90
+ if (err) res.status(404).send('TokenTalos Dashboard build not found.');
91
+ });
92
+ });
93
+ }
94
+
95
+ if (process.env.NODE_ENV !== 'test') {
96
+ (async () => {
97
+ if (await isPortInUse(PORT)) {
98
+ console.error(chalk.red.bold(`\n❌ Error: Port ${PORT} is already in use.`));
99
+ process.exit(1);
100
+ }
101
+
102
+ app.listen(PORT, '0.0.0.0', () => {
103
+ const serviceName = isCollectorOnly ? 'Collector' : (isDashboardOnly ? 'Dashboard' : 'TokenTalos');
104
+ console.log(chalk.cyan.bold(`\n🚀 ${serviceName} running at http://localhost:${PORT}`));
105
+ if (!isDashboardOnly && (config.enableCollector !== false)) console.log(chalk.gray(` API Endpoint: http://localhost:${PORT}/api/v1`));
106
+ });
107
+ })();
108
+ }
109
+
110
+ return app;
111
+ }
@@ -0,0 +1,45 @@
1
+ import { TokenTalosEngine } from '../../lib/engine/index.js';
2
+
3
+ /**
4
+ * Authentication Middleware
5
+ *
6
+ * Secure ingestion and execution endpoints.
7
+ * In Managed Mode: Requires valid X-TokenTalos-Key.
8
+ * In Local Mode: Optional key, defaults to default_org.
9
+ */
10
+ export async function authMiddleware(req, res, next) {
11
+ // Config is passed to the app during startServer
12
+ const config = req.app.get('tokentalosConfig');
13
+ const managedMode = config.managedMode || false;
14
+ const apiKey = req.headers['x-tokentalos-key'];
15
+
16
+ try {
17
+ const engine = new TokenTalosEngine(config);
18
+ await engine.init();
19
+
20
+ if (apiKey) {
21
+ const orgId = await engine.validateApiKey(apiKey);
22
+ if (orgId) {
23
+ req.orgId = orgId;
24
+ return next();
25
+ }
26
+
27
+ // If key provided but invalid
28
+ if (managedMode) {
29
+ return res.status(401).json({ error: 'Invalid API Key' });
30
+ }
31
+ }
32
+
33
+ // No key provided
34
+ if (managedMode) {
35
+ return res.status(401).json({ error: 'API Key required (X-TokenTalos-Key)' });
36
+ }
37
+
38
+ // Fallback for Local Mode
39
+ req.orgId = config.orgId || 'default_org';
40
+ next();
41
+ } catch (err) {
42
+ console.error('[TokenTalos] Auth Error:', err);
43
+ res.status(500).json({ error: 'Authentication internal error' });
44
+ }
45
+ }
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "tokentalos-api",
3
+ "version": "0.1.0",
4
+ "description": "",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node index.js",
9
+ "dev": "nodemon index.js",
10
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
11
+ },
12
+ "keywords": [],
13
+ "author": "",
14
+ "license": "ISC",
15
+ "devDependencies": {
16
+ "jest": "^29.0.0",
17
+ "nodemon": "^3.0.0",
18
+ "supertest": "^7.2.2"
19
+ },
20
+ "dependencies": {
21
+ "@anthropic-ai/sdk": "^0.76.0",
22
+ "@google/generative-ai": "^0.24.1",
23
+ "anthropic": "^0.0.0",
24
+ "axios": "^1.13.5",
25
+ "cli-table3": "^0.6.5",
26
+ "cors": "^2.8.6",
27
+ "dotenv": "^17.3.1",
28
+ "express": "^5.2.1",
29
+ "fs-extra": "^11.3.3",
30
+ "js-tiktoken": "^1.0.21",
31
+ "openai": "^6.22.0",
32
+ "path": "^0.12.7",
33
+ "pg": "^8.18.0",
34
+ "sqlite": "^5.1.1",
35
+ "sqlite3": "^5.1.7",
36
+ "uuid": "^13.0.0"
37
+ }
38
+ }
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadConfig, runSetup } from '../api/setup.js';
4
+ import { startServer } from '../api/index.js';
5
+ import chalk from 'chalk';
6
+ import Table from 'cli-table3';
7
+ import axios from 'axios';
8
+ import { Command } from 'commander';
9
+ import { exec, spawn } from 'child_process';
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
15
+ const program = new Command();
16
+
17
+ const getPidFile = (service) => path.join(process.cwd(), `.tokentalos-${service || 'full'}.pid`);
18
+
19
+ program
20
+ .name('tokentalos')
21
+ .description('Standalone LLM Token Usage Analyzer and Proxy')
22
+ .version('0.1.0');
23
+
24
+ program
25
+ .command('setup')
26
+ .description('Run the interactive configuration wizard')
27
+ .action(async () => {
28
+ await runSetup();
29
+ });
30
+
31
+ program
32
+ .command('start [service]')
33
+ .description('Start Token Talos services (all, collector, or dashboard)')
34
+ .option('-d, --daemon', 'Run in background', false)
35
+ .action(async (service, options) => {
36
+ const validServices = ['collector', 'dashboard'];
37
+ if (service && !validServices.includes(service)) {
38
+ console.log(chalk.red(`Invalid service: ${service}. Use 'collector', 'dashboard', or leave empty for both.`));
39
+ process.exit(1);
40
+ }
41
+
42
+ let config = await loadConfig();
43
+ if (!config) {
44
+ console.log(chalk.yellow('No configuration found. Starting setup...'));
45
+ config = await runSetup();
46
+ }
47
+
48
+ // Inject service context into config
49
+ if (service) config._service = service;
50
+
51
+ const pidFile = getPidFile(service);
52
+
53
+ if (options.daemon) {
54
+ if (fs.existsSync(pidFile)) {
55
+ console.log(chalk.red(`Token Talos ${service || ''} is already running (PID file exists).`));
56
+ process.exit(1);
57
+ }
58
+
59
+ const logFile = `./tokentalos-${service || 'all'}.log`;
60
+ const out = fs.openSync(logFile, 'a');
61
+ const err = fs.openSync(logFile, 'a');
62
+
63
+ const args = ['start'];
64
+ if (service) args.push(service);
65
+
66
+ const child = spawn('node', [path.join(__dirname, 'tokentalos.js'), ...args], {
67
+ detached: true,
68
+ stdio: ['ignore', out, err]
69
+ });
70
+
71
+ fs.writeFileSync(pidFile, child.pid.toString());
72
+ child.unref();
73
+ console.log(chalk.green(`Token Talos ${service || 'services'} started in background (PID: ${child.pid})`));
74
+ console.log(chalk.gray(`Logs: ${logFile}`));
75
+ process.exit(0);
76
+ } else {
77
+ await startServer(config);
78
+ }
79
+ });
80
+
81
+ program
82
+ .command('stop [service]')
83
+ .description('Stop Token Talos services (all, collector, or dashboard)')
84
+ .action(async (service) => {
85
+ const config = await loadConfig();
86
+ const pidFile = getPidFile(service);
87
+
88
+ // Determine port based on service
89
+ let port = config?.gatewayPort || 8060;
90
+ if (service === 'dashboard') port = config?.dashboardPort || 8060;
91
+
92
+ let stopped = false;
93
+
94
+ if (fs.existsSync(pidFile)) {
95
+ const pid = fs.readFileSync(pidFile, 'utf8');
96
+ try {
97
+ process.kill(parseInt(pid), 'SIGTERM');
98
+ fs.removeSync(pidFile);
99
+ console.log(chalk.green(`Token Talos ${service || ''} (PID: ${pid}) stopped.`));
100
+ stopped = true;
101
+ } catch (err) {
102
+ console.log(chalk.red(`Failed to stop Token Talos ${service || ''} via PID: ${err.message}`));
103
+ fs.removeSync(pidFile);
104
+ }
105
+ }
106
+
107
+ // Secondary check: kill by port if PID file didn't work
108
+ if (!stopped) {
109
+ console.log(chalk.gray(`Checking for processes on port ${port}...`));
110
+ try {
111
+ const { stdout } = await new Promise((resolve) => {
112
+ exec(`lsof -t -i:${port}`, (err, stdout) => resolve({ stdout }));
113
+ });
114
+
115
+ if (stdout && stdout.trim()) {
116
+ const pids = stdout.trim().split('\n');
117
+ for (const pidToKill of pids) {
118
+ process.kill(parseInt(pidToKill), 'SIGKILL');
119
+ console.log(chalk.green(`Process on port ${port} (PID: ${pidToKill}) terminated.`));
120
+ }
121
+ stopped = true;
122
+ }
123
+ } catch (err) {
124
+ // Silently ignore if lsof fails (likely no process)
125
+ }
126
+ }
127
+
128
+ if (!stopped) {
129
+ console.log(chalk.yellow(`No running Token Talos ${service || 'service'} found.`));
130
+ }
131
+ });
132
+
133
+ program
134
+ .command('dashboard')
135
+ .description('Launch the Token Talos Dashboard (Reader Mode)')
136
+ .action(async () => {
137
+ let config = await loadConfig();
138
+ if (!config) {
139
+ console.log(chalk.yellow('No configuration found. Starting setup...'));
140
+ config = await runSetup();
141
+ }
142
+ // Start server in foreground for dashboard access
143
+ console.log(chalk.blue('Launching Dashboard...'));
144
+ await startServer(config);
145
+ });
146
+
147
+ program
148
+ .command('stats')
149
+ .description('Show aggregate token and cost statistics')
150
+ .action(async () => {
151
+ const config = await loadConfig();
152
+ const port = config?.gatewayPort || 8060;
153
+ const API_URL = process.env.TOKENTALOS_API_URL || `http://localhost:${port}/api/v1`;
154
+ try {
155
+ const { data } = await axios.get(`${API_URL}/usage/stats`);
156
+ const table = new Table({ head: [chalk.blue('Metric'), chalk.blue('Value')] });
157
+ table.push(['Total Tokens', data.total_tokens.toLocaleString()]);
158
+ table.push(['Total Cost', `$${data.total_cost.toFixed(4)}`]);
159
+ table.push(['Total Requests', data.total_requests]);
160
+ console.log(table.toString());
161
+ } catch (err) {
162
+ console.error(chalk.red('Error: Could not connect to API. Is Token Talos running?'));
163
+ }
164
+ });
165
+
166
+ program
167
+ .command('list')
168
+ .description('List recent prompt logs')
169
+ .action(async () => {
170
+ const config = await loadConfig();
171
+ const port = config?.gatewayPort || 8060;
172
+ const API_URL = process.env.TOKENTALOS_API_URL || `http://localhost:${port}/api/v1`;
173
+ try {
174
+ const { data } = await axios.get(`${API_URL}/usage/recent`);
175
+ const table = new Table({ head: [chalk.blue('ID'), chalk.blue('Model'), chalk.blue('Tokens'), chalk.blue('Cost')] });
176
+ data.forEach(r => table.push([r.id.substring(0, 8), r.model, r.total_tokens, `$${r.total_cost.toFixed(4)}`]));
177
+ console.log(table.toString());
178
+ } catch (err) {
179
+ console.error(chalk.red('Error: Could not connect to API.'));
180
+ }
181
+ });
182
+
183
+ program
184
+ .command('export')
185
+ .description('Export usage logs to external formats')
186
+ .option('-f, --format <type>', 'Export format (jsonl, langsmith)', 'jsonl')
187
+ .option('-o, --output <path>', 'Output file path', './tokentalos_export.json')
188
+ .action(async (options) => {
189
+ const config = await loadConfig();
190
+ const port = config?.gatewayPort || 8060;
191
+ const API_URL = process.env.TOKENTALOS_API_URL || `http://localhost:${port}/api/v1`;
192
+ try {
193
+ const { data } = await axios.get(`${API_URL}/usage/recent?limit=1000`);
194
+
195
+ let outputData = '';
196
+ if (options.format === 'jsonl') {
197
+ outputData = data.map(r => JSON.stringify(r)).join('\n');
198
+ } else if (options.format === 'langsmith') {
199
+ // Simple mapping to LangSmith schema
200
+ outputData = JSON.stringify(data.map(r => ({
201
+ name: r.endpoint || 'tokentalos_gateway',
202
+ inputs: r.variables ? Object.fromEntries(r.variables.map(v => [v.name, v.content])) : {},
203
+ outputs: { content: '...' }, // Responses not stored in passive ingest
204
+ usage: { prompt_tokens: r.input_tokens, completion_tokens: r.output_tokens },
205
+ extra: { model: r.model, provider: r.provider }
206
+ })), null, 2);
207
+ }
208
+
209
+ fs.writeFileSync(options.output, outputData);
210
+ console.log(chalk.green(`Successfully exported ${data.length} records to ${options.output} (${options.format})`));
211
+ } catch (err) {
212
+ console.error(chalk.red(`Export failed: ${err.message}`));
213
+ }
214
+ });
215
+
216
+ // Handle default case
217
+ if (process.argv.length === 2) {
218
+ program.help();
219
+ }
220
+
221
+ program.parse(process.argv);
package/index.js ADDED
@@ -0,0 +1,151 @@
1
+ import { TokenTalosEngine } from './lib/engine/index.js';
2
+ import { TokenTalosPrompt } from './lib/engine/parameterizer.js';
3
+ import axios from 'axios';
4
+
5
+ /**
6
+ * TokenTalos Client SDK
7
+ *
8
+ * Supports both Standalone (direct DB) and Proxy (HTTP) modes.
9
+ */
10
+ export class TokenTalos {
11
+ /**
12
+ * @param {Object} options
13
+ * @param {string} [options.mode='standalone'] - 'standalone' or 'proxy'
14
+ * @param {string} [options.apiUrl] - Base URL for proxy mode
15
+ * @param {Object} [options.config] - TokenTalos configuration for standalone mode
16
+ */
17
+ constructor(options = {}) {
18
+ this.mode = options.mode || 'standalone';
19
+ this.apiUrl = options.apiUrl || 'http://localhost:8060/api/v1';
20
+ this.reportUrl = options.reportUrl || options.apiUrl || null; // URL to report usage to if in standalone
21
+ this.projectId = options.projectId || 'default';
22
+ this.apiKey = options.apiKey || null;
23
+
24
+ if (this.mode === 'standalone') {
25
+ this.engine = new TokenTalosEngine({
26
+ ...options.config,
27
+ projectId: this.projectId
28
+ });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Internal helper for reporting to collector
34
+ */
35
+ async _report(usageId, result, params) {
36
+ if (!this.reportUrl || this.mode === 'proxy') return;
37
+
38
+ try {
39
+ // Re-map engine result to ingestion format
40
+ const reportData = {
41
+ id: usageId,
42
+ projectId: this.projectId,
43
+ provider: params.provider,
44
+ model: params.model,
45
+ full_prompt: result.full_prompt_string || result.full_prompt || null,
46
+ response_content: result.content || null,
47
+ input_tokens: result.usage?.input_tokens || result.input_tokens || 0,
48
+ output_tokens: result.usage?.output_tokens || result.output_tokens || 0,
49
+ latency_ms: result.metadata?.latency_ms || 0,
50
+ endpoint: params.endpoint || 'sdk_standalone',
51
+ variables: result.variables || [],
52
+ actions_taken: result.metadata?.actions_taken || [],
53
+ timestamp: new Date().toISOString()
54
+ };
55
+
56
+ await axios.post(`${this.reportUrl}/usage/ingest`, reportData, {
57
+ headers: this._getHeaders()
58
+ });
59
+ } catch (err) {
60
+ console.warn('[TokenTalos] Failed to report usage to collector:', err.message);
61
+ }
62
+ }
63
+
64
+ async init() {
65
+ if (this.mode === 'standalone') {
66
+ await this.engine.init();
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Construct and process a prompt without executing
72
+ */
73
+ async construct(params) {
74
+ if (this.mode === 'standalone') {
75
+ const { processedParts, metadata } = await this.engine.process(params.parts);
76
+ const prompt = this.engine.createPrompt(params.provider, params.model);
77
+
78
+ for (const key in processedParts) {
79
+ if (key === 'system') prompt.addSystem(processedParts[key], params.parts[key]);
80
+ else if (key === 'context') prompt.addContext(processedParts[key], params.parts[key]);
81
+ else if (key === 'history') prompt.addHistory(processedParts[key], params.parts[key]);
82
+ else if (key === 'user_query') prompt.addUserQuery(processedParts[key], params.parts[key]);
83
+ else prompt.add(key, processedParts[key], params.parts[key]);
84
+ }
85
+
86
+ const trackingData = prompt.getTrackingData();
87
+ const result = {
88
+ id: trackingData.id,
89
+ messages: prompt.toMessages(),
90
+ full_prompt_string: prompt.toString(),
91
+ metadata,
92
+ variables: trackingData.variables
93
+ };
94
+
95
+ // Construction is "passive" ingestion - we report the intent
96
+ await this._report(trackingData.id, { ...result, input_tokens: trackingData.total_tokens }, params);
97
+
98
+ return result;
99
+ } else {
100
+ const { data } = await axios.post(`${this.apiUrl}/usage/prompt/construct`, {
101
+ projectId: this.projectId,
102
+ ...params
103
+ }, { headers: this._getHeaders() });
104
+ return data;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Execute a prompt through the Gateway
110
+ */
111
+ async execute(params) {
112
+ if (this.mode === 'standalone') {
113
+ const result = await this.engine.execute({
114
+ projectId: this.projectId,
115
+ ...params
116
+ });
117
+
118
+ // Report execution result
119
+ await this._report(result.id, result, params);
120
+
121
+ return result;
122
+ } else {
123
+ const { data } = await axios.post(`${this.apiUrl}/usage/prompt/execute`, {
124
+ projectId: this.projectId,
125
+ ...params
126
+ }, { headers: this._getHeaders() });
127
+ return data;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Verify reasoning chunks (OPV)
133
+ */
134
+ async verifyReasoning(params) {
135
+ if (this.mode === 'standalone') {
136
+ return await this.engine.verifyReasoning({
137
+ projectId: this.projectId,
138
+ ...params
139
+ });
140
+ } else {
141
+ const { data } = await axios.post(`${this.apiUrl}/opv/heartbeat`, {
142
+ projectId: this.projectId,
143
+ ...params
144
+ }, { headers: this._getHeaders() });
145
+ return data;
146
+ }
147
+ }
148
+ }
149
+
150
+ export { TokenTalosPrompt };
151
+ export default TokenTalos;
@@ -0,0 +1,66 @@
1
+ import { GoogleGenerativeAI } from '@google/generative-ai';
2
+ import { getCostCalculator } from './pricing.js';
3
+
4
+ export async function runAIAnalysis(config, usageRecord, variables) {
5
+ if (config.llmProvider !== 'gemini') return null;
6
+
7
+ try {
8
+ const genAI = new GoogleGenerativeAI(process.env.GOOGLE_API_KEY);
9
+ const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' });
10
+
11
+ const variableInfo = variables.map(v => `${v.name} (${v.token_count} tokens): "${v.content.substring(0, 100)}..."`).join('\n');
12
+
13
+ const prompt = `
14
+ Analyze this LLM prompt structure and suggest optimizations to reduce costs while maintaining performance.
15
+
16
+ Usage Context:
17
+ - Provider: ${usageRecord.provider}
18
+ - Model: ${usageRecord.model}
19
+ - Total Tokens: ${usageRecord.total_tokens}
20
+ - Total Cost: $${usageRecord.total_cost}
21
+
22
+ Variables:
23
+ ${variableInfo}
24
+
25
+ Return a JSON object with:
26
+ - detected_issues (array of strings)
27
+ - optimization_suggestions (array of strings)
28
+ - estimated_savings_pct (number)
29
+ `;
30
+
31
+ const result = await model.generateContent(prompt);
32
+ const response = await result.response;
33
+ const text = response.text();
34
+
35
+ // Attempt to parse JSON from response
36
+ try {
37
+ const jsonStart = text.indexOf('{');
38
+ const jsonEnd = text.lastIndexOf('}') + 1;
39
+ const analysis = JSON.parse(text.substring(jsonStart, jsonEnd));
40
+
41
+ const calculator = getCostCalculator();
42
+ const mceResult = calculator.getBestAlternative(
43
+ usageRecord.provider,
44
+ usageRecord.model,
45
+ usageRecord.input_tokens,
46
+ usageRecord.output_tokens,
47
+ config.comparisonProviders
48
+ );
49
+
50
+ if (mceResult) {
51
+ analysis.mce_best_alternative_model = mceResult.model;
52
+ analysis.mce_best_alternative_provider = mceResult.provider;
53
+ analysis.mce_best_alternative_cost = mceResult.cost;
54
+ analysis.mce_savings_pct = ((usageRecord.total_cost - mceResult.cost) / usageRecord.total_cost) * 100;
55
+ }
56
+
57
+ return analysis;
58
+ } catch (err) {
59
+ console.warn('AI analysis JSON parsing failed:', err);
60
+ return null;
61
+ }
62
+ } catch (err) {
63
+ console.error('AI analysis error:', err);
64
+ return null;
65
+ }
66
+ }