@goldensheepai/toknxr-cli 0.2.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/.env +21 -0
- package/.env.example +21 -0
- package/README.md +238 -0
- package/interactions.log +8 -0
- package/lib/ai-analytics.js +296 -0
- package/lib/auth.js +73 -0
- package/lib/cli.js +382 -0
- package/lib/code-analysis.js +304 -0
- package/lib/code-review.js +319 -0
- package/lib/config.js +7 -0
- package/lib/dashboard.js +363 -0
- package/lib/hallucination-detector.js +272 -0
- package/lib/policy.js +49 -0
- package/lib/pricing.js +20 -0
- package/lib/proxy.js +359 -0
- package/lib/sync.js +95 -0
- package/package.json +38 -0
- package/src/ai-analytics.ts +418 -0
- package/src/auth.ts +80 -0
- package/src/cli.ts +447 -0
- package/src/code-analysis.ts +365 -0
- package/src/config.ts +10 -0
- package/src/dashboard.tsx +391 -0
- package/src/hallucination-detector.ts +368 -0
- package/src/policy.ts +55 -0
- package/src/pricing.ts +21 -0
- package/src/proxy.ts +438 -0
- package/src/sync.ts +129 -0
- package/start.sh +56 -0
- package/test-analysis.mjs +77 -0
- package/test-coding.mjs +27 -0
- package/test-generate-sample-data.js +118 -0
- package/test-proxy.mjs +25 -0
- package/toknxr.config.json +63 -0
- package/toknxr.policy.json +18 -0
- package/tsconfig.json +19 -0
package/lib/policy.js
ADDED
@@ -0,0 +1,49 @@
|
|
1
|
+
import * as fs from 'node:fs';
|
2
|
+
import * as path from 'node:path';
|
3
|
+
import axios from 'axios';
|
4
|
+
export function loadPolicy(cwd = process.cwd()) {
|
5
|
+
const policyPath = path.resolve(cwd, 'toknxr.policy.json');
|
6
|
+
if (!fs.existsSync(policyPath))
|
7
|
+
return null;
|
8
|
+
try {
|
9
|
+
const raw = fs.readFileSync(policyPath, 'utf8');
|
10
|
+
return JSON.parse(raw);
|
11
|
+
}
|
12
|
+
catch (error) {
|
13
|
+
console.error('Error loading policy file:', error);
|
14
|
+
return null;
|
15
|
+
}
|
16
|
+
}
|
17
|
+
export function currentMonthKey(date = new Date()) {
|
18
|
+
return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}`;
|
19
|
+
}
|
20
|
+
export function computeMonthlySpend(logFilePath, monthKey) {
|
21
|
+
const sums = { total: 0, byProvider: {} };
|
22
|
+
if (!fs.existsSync(logFilePath))
|
23
|
+
return sums;
|
24
|
+
const lines = fs.readFileSync(logFilePath, 'utf8').trim().split('\n').filter(Boolean);
|
25
|
+
for (const line of lines) {
|
26
|
+
try {
|
27
|
+
const j = JSON.parse(line);
|
28
|
+
const ts = new Date(j.timestamp);
|
29
|
+
const key = currentMonthKey(ts);
|
30
|
+
if (key !== monthKey)
|
31
|
+
continue;
|
32
|
+
const cost = Number(j.costUSD || 0);
|
33
|
+
sums.total += cost;
|
34
|
+
sums.byProvider[j.provider] = (sums.byProvider[j.provider] || 0) + cost;
|
35
|
+
}
|
36
|
+
catch (error) {
|
37
|
+
console.warn('Skipping invalid log entry in policy check', error);
|
38
|
+
}
|
39
|
+
}
|
40
|
+
return sums;
|
41
|
+
}
|
42
|
+
export async function sendBudgetAlert(webhookUrl, payload) {
|
43
|
+
try {
|
44
|
+
await axios.post(webhookUrl, payload, { timeout: 5000 });
|
45
|
+
}
|
46
|
+
catch (error) {
|
47
|
+
console.error('Error sending budget alert:', error);
|
48
|
+
}
|
49
|
+
}
|
package/lib/pricing.js
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
export const modelToPricing = {
|
2
|
+
// Gemini (Free tier available)
|
3
|
+
'gemini-2.5-flash': { promptPer1k: 0.15, completionPer1k: 0.60 },
|
4
|
+
'gemini-2.5-pro': { promptPer1k: 0.50, completionPer1k: 1.50 },
|
5
|
+
'gemini-flash-latest': { promptPer1k: 0.15, completionPer1k: 0.60 },
|
6
|
+
'gemini-pro-latest': { promptPer1k: 0.50, completionPer1k: 1.50 },
|
7
|
+
// OpenAI (Free tier available for some models)
|
8
|
+
'gpt-4o-mini': { promptPer1k: 0.15, completionPer1k: 0.60 },
|
9
|
+
'gpt-4o': { promptPer1k: 5.00, completionPer1k: 15.00 },
|
10
|
+
// Free tier models (zero cost)
|
11
|
+
'ollama-llama3': { promptPer1k: 0.00, completionPer1k: 0.00 },
|
12
|
+
'local-model': { promptPer1k: 0.00, completionPer1k: 0.00 },
|
13
|
+
};
|
14
|
+
export function estimateCostUSD(model, promptTokens, completionTokens) {
|
15
|
+
const pricing = modelToPricing[model] || modelToPricing['gemini-2.5-flash'];
|
16
|
+
const promptK = promptTokens / 1000;
|
17
|
+
const completionK = completionTokens / 1000;
|
18
|
+
const cost = promptK * pricing.promptPer1k + completionK * pricing.completionPer1k;
|
19
|
+
return Number(cost.toFixed(6));
|
20
|
+
}
|
package/lib/proxy.js
ADDED
@@ -0,0 +1,359 @@
|
|
1
|
+
import * as http from 'http';
|
2
|
+
import 'dotenv/config';
|
3
|
+
import chalk from 'chalk';
|
4
|
+
import axios from 'axios';
|
5
|
+
import * as fs from 'node:fs';
|
6
|
+
import * as path from 'node:path';
|
7
|
+
import { randomUUID } from 'node:crypto';
|
8
|
+
import { estimateCostUSD } from './pricing.js';
|
9
|
+
import { loadPolicy, currentMonthKey, computeMonthlySpend, sendBudgetAlert } from './policy.js';
|
10
|
+
import { analyzeCodeQuality, scoreEffectiveness, extractCodeFromResponse } from './code-analysis.js';
|
11
|
+
import { hallucinationDetector } from './hallucination-detector.js';
|
12
|
+
const PORT = 8788;
|
13
|
+
// Helper to resolve dot notation paths
|
14
|
+
const getValueFromPath = (obj, path) => {
|
15
|
+
if (!path || !obj)
|
16
|
+
return 0;
|
17
|
+
try {
|
18
|
+
const result = path.split('.').reduce((res, prop) => res && typeof res === 'object' && prop in res ? res[prop] : undefined, obj);
|
19
|
+
return Number(result) || 0;
|
20
|
+
}
|
21
|
+
catch {
|
22
|
+
return 0;
|
23
|
+
}
|
24
|
+
};
|
25
|
+
export const startProxyServer = async () => {
|
26
|
+
// --- Load Provider Config ---
|
27
|
+
let providerConfig;
|
28
|
+
const configPath = path.resolve(process.cwd(), 'toknxr.config.json');
|
29
|
+
if (!fs.existsSync(configPath)) {
|
30
|
+
console.error(chalk.red(`[Proxy] Error: 'toknxr.config.json' not found in the current directory.`));
|
31
|
+
console.error(chalk.yellow(`[Proxy] Please create one based on the 'ai-config-template.json' in the foundation pack.`));
|
32
|
+
process.exit(1);
|
33
|
+
}
|
34
|
+
try {
|
35
|
+
const configFile = fs.readFileSync(configPath, 'utf8');
|
36
|
+
providerConfig = JSON.parse(configFile);
|
37
|
+
console.log(chalk.green('[Proxy] Successfully loaded provider configuration.'));
|
38
|
+
}
|
39
|
+
catch (error) {
|
40
|
+
console.error(chalk.red('[Proxy] Error parsing toknxr.config.json:', error));
|
41
|
+
process.exit(1);
|
42
|
+
}
|
43
|
+
// --------------------------
|
44
|
+
const server = http.createServer(async (req, res) => {
|
45
|
+
const requestId = randomUUID();
|
46
|
+
console.log(chalk.blue(`[Proxy] Received request: ${req.method} ${req.url}`));
|
47
|
+
// Health check endpoint
|
48
|
+
if (req.method === 'GET' && req.url === '/health') {
|
49
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
50
|
+
res.end(JSON.stringify({ status: 'ok' }));
|
51
|
+
return;
|
52
|
+
}
|
53
|
+
// Redirect root to dashboard for convenience
|
54
|
+
if (req.method === 'GET' && (req.url === '/' || req.url === '')) {
|
55
|
+
res.writeHead(302, { Location: '/dashboard' });
|
56
|
+
res.end();
|
57
|
+
return;
|
58
|
+
}
|
59
|
+
// Enhanced stats API for dashboard
|
60
|
+
if (req.method === 'GET' && req.url === '/api/stats') {
|
61
|
+
const logFilePath = path.resolve(process.cwd(), 'interactions.log');
|
62
|
+
const monthKey = currentMonthKey();
|
63
|
+
const sums = computeMonthlySpend(logFilePath, monthKey);
|
64
|
+
let recentInteractions = [];
|
65
|
+
if (fs.existsSync(logFilePath)) {
|
66
|
+
try {
|
67
|
+
const fileContent = fs.readFileSync(logFilePath, 'utf8');
|
68
|
+
const lines = fileContent.trim().split('\n');
|
69
|
+
recentInteractions = lines.slice(-20) // Last 20 interactions
|
70
|
+
.map(line => JSON.parse(line))
|
71
|
+
.map((interaction) => ({
|
72
|
+
timestamp: interaction.timestamp,
|
73
|
+
provider: interaction.provider,
|
74
|
+
model: interaction.model,
|
75
|
+
cost: interaction.costUSD || 0,
|
76
|
+
taskType: interaction.taskType,
|
77
|
+
qualityScore: interaction.codeQualityScore,
|
78
|
+
effectivenessScore: interaction.effectivenessScore
|
79
|
+
}))
|
80
|
+
.reverse(); // Most recent first
|
81
|
+
}
|
82
|
+
catch (error) {
|
83
|
+
console.error('Error reading recent interactions:', error);
|
84
|
+
}
|
85
|
+
}
|
86
|
+
// Calculate waste rate based on quality scores
|
87
|
+
const codingInteractions = recentInteractions.filter(i => i.taskType === 'coding');
|
88
|
+
const wasteRate = codingInteractions.length > 0
|
89
|
+
? (codingInteractions.filter(i => (i.qualityScore || 0) < 70).length / codingInteractions.length) * 100
|
90
|
+
: 0;
|
91
|
+
// Count total interactions from recent interactions for this month
|
92
|
+
const monthInteractions = recentInteractions.filter(i => {
|
93
|
+
const interactionDate = new Date(i.timestamp);
|
94
|
+
const interactionMonth = currentMonthKey(interactionDate);
|
95
|
+
return interactionMonth === monthKey;
|
96
|
+
});
|
97
|
+
const enhancedStats = {
|
98
|
+
monthKey,
|
99
|
+
totals: sums,
|
100
|
+
recentInteractions,
|
101
|
+
wasteRate,
|
102
|
+
summary: {
|
103
|
+
totalCost: sums.total || 0,
|
104
|
+
totalInteractions: monthInteractions.length,
|
105
|
+
avgCostPerTask: monthInteractions.length ? (sums.total / monthInteractions.length) : 0,
|
106
|
+
wasteRate
|
107
|
+
}
|
108
|
+
};
|
109
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
110
|
+
res.end(JSON.stringify(enhancedStats));
|
111
|
+
return;
|
112
|
+
}
|
113
|
+
// Enhanced React Dashboard
|
114
|
+
if (req.method === 'GET' && req.url === '/dashboard') {
|
115
|
+
const html = `<!DOCTYPE html>
|
116
|
+
<html lang="en">
|
117
|
+
<head>
|
118
|
+
<meta charset="UTF-8">
|
119
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
120
|
+
<title>TokNXR Dashboard - AI Analytics</title>
|
121
|
+
<style>
|
122
|
+
/* Inline critical CSS for better loading experience */
|
123
|
+
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
|
124
|
+
#dashboard-root { min-height: 100vh; }
|
125
|
+
.loading {
|
126
|
+
display: flex;
|
127
|
+
align-items: center;
|
128
|
+
justify-content: center;
|
129
|
+
height: 100vh;
|
130
|
+
font-size: 18px;
|
131
|
+
color: #64748b;
|
132
|
+
}
|
133
|
+
</style>
|
134
|
+
</head>
|
135
|
+
<body>
|
136
|
+
<div id="dashboard-root">
|
137
|
+
<div class="loading">Loading TokNXR Dashboard...</div>
|
138
|
+
</div>
|
139
|
+
<script>
|
140
|
+
// Inline the dashboard script
|
141
|
+
${fs.readFileSync(path.resolve(process.cwd(), 'src/dashboard.tsx'), 'utf8').replace(/export default function Dashboard/, 'function Dashboard').replace(/import React.*$/m, '').replace(/import { createRoot }.*$/m, '')}
|
142
|
+
</script>
|
143
|
+
</body>
|
144
|
+
</html>`;
|
145
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
146
|
+
res.end(html);
|
147
|
+
return;
|
148
|
+
}
|
149
|
+
const matchedProvider = providerConfig.providers.find((p) => req.url?.startsWith(p.routePrefix));
|
150
|
+
if (req.method === 'POST' && matchedProvider) {
|
151
|
+
try {
|
152
|
+
console.log(chalk.gray(`[Proxy] Matched provider: ${matchedProvider.name} | requestId=${requestId}`));
|
153
|
+
const chunks = [];
|
154
|
+
for await (const chunk of req) {
|
155
|
+
chunks.push(chunk);
|
156
|
+
}
|
157
|
+
const requestBody = Buffer.concat(chunks).toString();
|
158
|
+
const requestData = JSON.parse(requestBody);
|
159
|
+
// --- Hard budget enforcement (pre-flight) ---
|
160
|
+
const prePolicy = loadPolicy(process.cwd());
|
161
|
+
if (prePolicy) {
|
162
|
+
const preLogPath = path.resolve(process.cwd(), 'interactions.log');
|
163
|
+
const preMonth = currentMonthKey();
|
164
|
+
const preSums = computeMonthlySpend(preLogPath, preMonth);
|
165
|
+
let overCap = false;
|
166
|
+
const reasons = [];
|
167
|
+
if (prePolicy.monthlyUSD && preSums.total > prePolicy.monthlyUSD) {
|
168
|
+
overCap = true;
|
169
|
+
reasons.push(`total>${prePolicy.monthlyUSD}`);
|
170
|
+
}
|
171
|
+
if (prePolicy.perProviderMonthlyUSD) {
|
172
|
+
const cap = prePolicy.perProviderMonthlyUSD[matchedProvider.name];
|
173
|
+
if (typeof cap === 'number') {
|
174
|
+
const spent = (preSums.byProvider[matchedProvider.name] || 0);
|
175
|
+
if (spent > cap) {
|
176
|
+
overCap = true;
|
177
|
+
reasons.push(`${matchedProvider.name}>${cap}`);
|
178
|
+
}
|
179
|
+
}
|
180
|
+
}
|
181
|
+
if (overCap) {
|
182
|
+
console.log(chalk.red(`[Proxy] Hard budget enforcement: blocking request | reasons=${reasons.join(', ')} | requestId=${requestId}`));
|
183
|
+
res.writeHead(429, { 'Content-Type': 'application/json' });
|
184
|
+
res.end(JSON.stringify({ error: 'Budget exceeded', reasons, requestId }));
|
185
|
+
return;
|
186
|
+
}
|
187
|
+
}
|
188
|
+
// --- Dynamic Request Forwarding ---
|
189
|
+
const headers = { 'Content-Type': 'application/json' };
|
190
|
+
if (matchedProvider.apiKeyEnvVar) {
|
191
|
+
const apiKey = process.env[matchedProvider.apiKeyEnvVar];
|
192
|
+
if (!apiKey) {
|
193
|
+
throw new Error(`${matchedProvider.apiKeyEnvVar} environment variable not set.`);
|
194
|
+
}
|
195
|
+
const authHeader = matchedProvider.authHeader || 'Authorization';
|
196
|
+
const authScheme = matchedProvider.authScheme ? `${matchedProvider.authScheme} ` : '';
|
197
|
+
headers[authHeader] = `${authScheme}${apiKey}`;
|
198
|
+
}
|
199
|
+
const targetUrl = matchedProvider.targetUrl.replace(/\/$/, '') + (req.url || '').substring(matchedProvider.routePrefix.length);
|
200
|
+
console.log(chalk.gray(`[Proxy] Forwarding request to ${targetUrl} | requestId=${requestId}`));
|
201
|
+
const apiResponse = await axios.post(targetUrl, requestData, {
|
202
|
+
headers,
|
203
|
+
timeout: 20000,
|
204
|
+
validateStatus: () => true,
|
205
|
+
});
|
206
|
+
if (apiResponse.status >= 500) {
|
207
|
+
// simple retry with exponential backoff
|
208
|
+
const backoffs = [300, 600, 1200];
|
209
|
+
for (const ms of backoffs) {
|
210
|
+
await new Promise(r => setTimeout(r, ms));
|
211
|
+
const retry = await axios.post(targetUrl, requestData, { headers, timeout: 20000, validateStatus: () => true });
|
212
|
+
if (retry.status < 500) {
|
213
|
+
apiResponse.status = retry.status;
|
214
|
+
apiResponse.data = retry.data;
|
215
|
+
break;
|
216
|
+
}
|
217
|
+
}
|
218
|
+
}
|
219
|
+
if (apiResponse.status >= 400) {
|
220
|
+
throw new Error(`Upstream error ${apiResponse.status}`);
|
221
|
+
}
|
222
|
+
const responseData = apiResponse.data;
|
223
|
+
// --------------------------------
|
224
|
+
// --- Extract User Prompt and AI Response for Analysis ---
|
225
|
+
console.log(chalk.cyan(`[Proxy] Extracting request/response content for analysis... | requestId=${requestId}`));
|
226
|
+
// Extract user prompt from request (Gemini format)
|
227
|
+
let userPrompt = '';
|
228
|
+
if (requestData.contents && requestData.contents[0]?.parts?.[0]?.text) {
|
229
|
+
userPrompt = requestData.contents[0].parts[0].text;
|
230
|
+
}
|
231
|
+
// Extract AI response text (Gemini format)
|
232
|
+
let aiResponseText = '';
|
233
|
+
if (responseData.candidates && responseData.candidates[0]?.content?.parts?.[0]?.text) {
|
234
|
+
aiResponseText = responseData.candidates[0].content.parts[0].text;
|
235
|
+
}
|
236
|
+
// Check if this appears to be a coding request
|
237
|
+
const isCodeRequest = /code|function|script|program|algorithm|implement/i.test(userPrompt) ||
|
238
|
+
/```.*\n[\s\S]*```/.test(aiResponseText);
|
239
|
+
// --- Dynamic Data Extraction ---
|
240
|
+
console.log(chalk.cyan(`[Proxy] Extracting interaction data... | requestId=${requestId}`));
|
241
|
+
const mapping = matchedProvider.tokenMapping;
|
242
|
+
const promptTokens = getValueFromPath(responseData, mapping.prompt);
|
243
|
+
const completionTokens = getValueFromPath(responseData, mapping.completion);
|
244
|
+
const totalTokens = getValueFromPath(responseData, mapping.total) || promptTokens + completionTokens;
|
245
|
+
const interactionData = {
|
246
|
+
timestamp: new Date().toISOString(),
|
247
|
+
provider: matchedProvider.name,
|
248
|
+
model: responseData.model || 'gemini-2.5-flash',
|
249
|
+
promptTokens: promptTokens,
|
250
|
+
completionTokens: completionTokens,
|
251
|
+
totalTokens: totalTokens,
|
252
|
+
costUSD: estimateCostUSD(responseData.model || 'gemini-2.5-flash', promptTokens, completionTokens),
|
253
|
+
taskType: isCodeRequest ? 'coding' : 'chat',
|
254
|
+
};
|
255
|
+
// --- Enhanced AI Analysis (for all requests) ---
|
256
|
+
if (userPrompt && aiResponseText) {
|
257
|
+
console.log(chalk.cyan(`[Proxy] Running AI analysis pipeline... | requestId=${requestId}`));
|
258
|
+
// Store original texts for analysis
|
259
|
+
interactionData.userPrompt = userPrompt;
|
260
|
+
interactionData.aiResponse = aiResponseText;
|
261
|
+
// Run hallucination detection on all interactions
|
262
|
+
const hallucinationDetection = hallucinationDetector.detectHallucination(userPrompt, aiResponseText);
|
263
|
+
// Add hallucination data to interaction (will be serialized to JSON)
|
264
|
+
interactionData.hallucinationDetection = hallucinationDetection;
|
265
|
+
console.log(chalk.cyan(`[Proxy] Hallucination detection complete - Confidence: ${hallucinationDetection.confidence}%, Likely: ${hallucinationDetection.isLikelyHallucination} | requestId=${requestId}`));
|
266
|
+
// Code Quality Analysis (if this is a coding request)
|
267
|
+
if (isCodeRequest) {
|
268
|
+
console.log(chalk.cyan(`[Proxy] Running code quality analysis... | requestId=${requestId}`));
|
269
|
+
// Extract code from response
|
270
|
+
const extractedCodeResult = extractCodeFromResponse(aiResponseText);
|
271
|
+
if (extractedCodeResult) {
|
272
|
+
interactionData.extractedCode = extractedCodeResult.code;
|
273
|
+
// Analyze code quality
|
274
|
+
const qualityMetrics = analyzeCodeQuality(extractedCodeResult.code, extractedCodeResult.language);
|
275
|
+
interactionData.codeQualityMetrics = qualityMetrics;
|
276
|
+
// Calculate overall quality score (0-100)
|
277
|
+
let qualityScore = 50; // Base
|
278
|
+
if (qualityMetrics.syntaxValid)
|
279
|
+
qualityScore += 20;
|
280
|
+
qualityScore += Math.round(qualityMetrics.estimatedReadability * 2); // 0-20
|
281
|
+
if (qualityMetrics.hasFunctions || qualityMetrics.hasClasses)
|
282
|
+
qualityScore += 15;
|
283
|
+
if (qualityMetrics.potentialIssues.length === 0)
|
284
|
+
qualityScore += 10;
|
285
|
+
if (qualityMetrics.linesOfCode > 20)
|
286
|
+
qualityScore += 5; // Substantial implementation
|
287
|
+
interactionData.codeQualityScore = Math.min(100, qualityScore);
|
288
|
+
// Score effectiveness (how well the AI understood and fulfilled the request)
|
289
|
+
const effectiveness = scoreEffectiveness(userPrompt, aiResponseText, extractedCodeResult.code);
|
290
|
+
interactionData.effectivenessScore = effectiveness.overallEffectiveness;
|
291
|
+
console.log(chalk.green(`[Proxy] Code analysis complete - Quality: ${qualityScore}/100, Effectiveness: ${effectiveness.overallEffectiveness}/100 | requestId=${requestId}`));
|
292
|
+
}
|
293
|
+
}
|
294
|
+
}
|
295
|
+
// ---------------------------
|
296
|
+
// --- Local File Logging ---
|
297
|
+
console.log(chalk.cyan(`[Proxy] Logging interaction to local file... | requestId=${requestId}`));
|
298
|
+
const logFilePath = path.resolve(process.cwd(), 'interactions.log');
|
299
|
+
const logLine = JSON.stringify({ requestId, ...interactionData });
|
300
|
+
try {
|
301
|
+
const stats = fs.existsSync(logFilePath) ? fs.statSync(logFilePath) : null;
|
302
|
+
if (stats && stats.size > 5 * 1024 * 1024) {
|
303
|
+
const rotated = `${logFilePath.replace(/\.log$/, '')}.${Date.now()}.log`;
|
304
|
+
fs.renameSync(logFilePath, rotated);
|
305
|
+
console.log(chalk.gray(`[Proxy] Rotated log to ${rotated}`));
|
306
|
+
}
|
307
|
+
}
|
308
|
+
catch { }
|
309
|
+
fs.appendFileSync(logFilePath, logLine + '\n');
|
310
|
+
console.log(chalk.green(`[Proxy] Interaction successfully logged to ${logFilePath} | requestId=${requestId}`));
|
311
|
+
// --- Budgets and Alerts ---
|
312
|
+
const policy = loadPolicy(process.cwd());
|
313
|
+
if (policy) {
|
314
|
+
const monthKey = currentMonthKey();
|
315
|
+
const sums = computeMonthlySpend(logFilePath, monthKey);
|
316
|
+
const breached = [];
|
317
|
+
if (policy.monthlyUSD && sums.total > policy.monthlyUSD) {
|
318
|
+
breached.push(`total>${policy.monthlyUSD}`);
|
319
|
+
}
|
320
|
+
if (policy.perProviderMonthlyUSD) {
|
321
|
+
for (const p in policy.perProviderMonthlyUSD) {
|
322
|
+
const cap = policy.perProviderMonthlyUSD[p];
|
323
|
+
const spent = sums.byProvider[p] || 0;
|
324
|
+
if (spent > cap)
|
325
|
+
breached.push(`${p}>${cap}`);
|
326
|
+
}
|
327
|
+
}
|
328
|
+
if (breached.length && policy.webhookUrl) {
|
329
|
+
await sendBudgetAlert(policy.webhookUrl, {
|
330
|
+
requestId,
|
331
|
+
monthKey,
|
332
|
+
breaches: breached,
|
333
|
+
totals: sums,
|
334
|
+
});
|
335
|
+
console.log(chalk.red(`[Proxy] Budget breach detected (${breached.join(', ')}) | requestId=${requestId}`));
|
336
|
+
}
|
337
|
+
}
|
338
|
+
// --------------------------
|
339
|
+
res.writeHead(apiResponse.status, apiResponse.headers);
|
340
|
+
res.end(JSON.stringify(responseData));
|
341
|
+
console.log(chalk.magenta(`[Proxy] Request successfully proxied and data tracked. | requestId=${requestId}`));
|
342
|
+
}
|
343
|
+
catch (error) {
|
344
|
+
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
345
|
+
console.error(chalk.red(`[Proxy] Error: ${errorMessage} | requestId=${requestId}`));
|
346
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
347
|
+
res.end(JSON.stringify({ error: 'Failed to proxy request', requestId }));
|
348
|
+
}
|
349
|
+
}
|
350
|
+
else {
|
351
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
352
|
+
res.end('Not Found. No matching provider route in toknxr.config.json.');
|
353
|
+
}
|
354
|
+
});
|
355
|
+
server.listen(PORT, () => {
|
356
|
+
console.log(chalk.yellow(`[Proxy] Server listening on http://localhost:${PORT}`));
|
357
|
+
console.log(chalk.yellow('Loaded providers:', providerConfig.providers.map((p) => p.name).join(', ')));
|
358
|
+
});
|
359
|
+
};
|
package/lib/sync.js
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
import fs from 'fs';
|
2
|
+
import path from 'path';
|
3
|
+
import { getToken } from './auth.js';
|
4
|
+
import { setAuthToken } from './config.js';
|
5
|
+
import chalk from 'chalk';
|
6
|
+
export async function syncInteractions(supabase, options) {
|
7
|
+
console.log(chalk.blue('Syncing local analytics with the cloud dashboard...'));
|
8
|
+
// Get the stored token
|
9
|
+
const token = await getToken();
|
10
|
+
if (!token) {
|
11
|
+
console.error(chalk.red('No authentication token found. Please login first with `toknxr login`.'));
|
12
|
+
process.exit(1);
|
13
|
+
}
|
14
|
+
// Set auth in Supabase
|
15
|
+
setAuthToken(token);
|
16
|
+
const logFilePath = path.resolve(process.cwd(), 'interactions.log');
|
17
|
+
if (!fs.existsSync(logFilePath)) {
|
18
|
+
console.log(chalk.yellow('No interactions.log file found. Nothing to sync.'));
|
19
|
+
return;
|
20
|
+
}
|
21
|
+
const fileContent = fs.readFileSync(logFilePath, 'utf8');
|
22
|
+
const lines = fileContent.trim().split('\n');
|
23
|
+
if (lines.length === 0 || (lines.length === 1 && lines[0] === '')) {
|
24
|
+
console.log(chalk.yellow('Log file is empty. Nothing to sync.'));
|
25
|
+
return;
|
26
|
+
}
|
27
|
+
const logs = [];
|
28
|
+
for (const line of lines) {
|
29
|
+
try {
|
30
|
+
const log = JSON.parse(line);
|
31
|
+
logs.push(log);
|
32
|
+
}
|
33
|
+
catch (error) {
|
34
|
+
console.warn(`Skipping invalid log entry: ${line}`);
|
35
|
+
}
|
36
|
+
}
|
37
|
+
if (logs.length === 0) {
|
38
|
+
console.log('No valid interactions to sync.');
|
39
|
+
return;
|
40
|
+
}
|
41
|
+
// Map logs to interactions
|
42
|
+
const interactions = [];
|
43
|
+
for (const log of logs) {
|
44
|
+
// Find ai_service_id by provider and model
|
45
|
+
const { data: aiService, error: aiError } = await supabase
|
46
|
+
.from('ai_services')
|
47
|
+
.select('id')
|
48
|
+
.eq('provider', log.provider)
|
49
|
+
.eq('name', log.model)
|
50
|
+
.single();
|
51
|
+
if (aiError || !aiService) {
|
52
|
+
console.warn(`AI service not found for provider: ${log.provider}, model: ${log.model}`);
|
53
|
+
continue;
|
54
|
+
}
|
55
|
+
// Assume project_id - for now, use a default or query user's projects
|
56
|
+
// TODO: Add project selection or default project
|
57
|
+
const { data: projects, error: projError } = await supabase
|
58
|
+
.from('projects')
|
59
|
+
.select('id')
|
60
|
+
.limit(1);
|
61
|
+
if (projError || !projects || projects.length === 0) {
|
62
|
+
console.error('No projects found for user.');
|
63
|
+
continue;
|
64
|
+
}
|
65
|
+
const projectId = projects[0].id;
|
66
|
+
const interaction = {
|
67
|
+
project_id: projectId,
|
68
|
+
ai_service_id: aiService.id,
|
69
|
+
tokens_used: log.totalTokens,
|
70
|
+
cost_in_cents: Math.round(log.costUSD * 100),
|
71
|
+
timestamp: new Date(log.timestamp).toISOString(),
|
72
|
+
request_details: log.userPrompt || '',
|
73
|
+
response_details: log.aiResponse || log.extractedCode || '',
|
74
|
+
};
|
75
|
+
interactions.push(interaction);
|
76
|
+
}
|
77
|
+
if (interactions.length === 0) {
|
78
|
+
console.log('No interactions to insert.');
|
79
|
+
return;
|
80
|
+
}
|
81
|
+
// Insert interactions
|
82
|
+
const { error: insertError } = await supabase
|
83
|
+
.from('interactions')
|
84
|
+
.insert(interactions);
|
85
|
+
if (insertError) {
|
86
|
+
console.error('Error syncing interactions:', insertError);
|
87
|
+
}
|
88
|
+
else {
|
89
|
+
console.log(`Successfully synced ${interactions.length} interactions.`);
|
90
|
+
}
|
91
|
+
if (options.clear) {
|
92
|
+
fs.writeFileSync(logFilePath, '');
|
93
|
+
console.log(chalk.gray('Local interactions.log has been cleared.'));
|
94
|
+
}
|
95
|
+
}
|
package/package.json
ADDED
@@ -0,0 +1,38 @@
|
|
1
|
+
{
|
2
|
+
"name": "@goldensheepai/toknxr-cli",
|
3
|
+
"version": "0.2.0",
|
4
|
+
"license": "ISC",
|
5
|
+
"type": "module",
|
6
|
+
"bin": {
|
7
|
+
"toknxr": "lib/cli.js"
|
8
|
+
},
|
9
|
+
"scripts": {
|
10
|
+
"start": "tsx src/cli.ts start",
|
11
|
+
"cli": "tsx src/cli.ts",
|
12
|
+
"build": "tsc",
|
13
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
14
|
+
},
|
15
|
+
"dependencies": {
|
16
|
+
"@supabase/supabase-js": "^2.47.1",
|
17
|
+
"commander": "^14.0.1",
|
18
|
+
"chalk": "^5.6.2",
|
19
|
+
"axios": "^1.12.2",
|
20
|
+
"open": "^10.2.0",
|
21
|
+
"keytar": "^7.9.0",
|
22
|
+
"ora": "^9.0.0",
|
23
|
+
"cli-spinners": "^3.3.0",
|
24
|
+
"cli-cursor": "^5.0.0",
|
25
|
+
"onetime": "^7.0.0",
|
26
|
+
"is-interactive": "^2.0.0",
|
27
|
+
"@types/keytar": "^4.4.0",
|
28
|
+
"dotenv": "^16.4.5"
|
29
|
+
},
|
30
|
+
"devDependencies": {
|
31
|
+
"@types/node": "^24.6.2",
|
32
|
+
"@types/chalk": "^2.2.4",
|
33
|
+
"@types/open": "^6.2.1",
|
34
|
+
"typescript": "^5.9.3",
|
35
|
+
"tsx": "^4.20.6",
|
36
|
+
"esbuild": "^0.25.10"
|
37
|
+
}
|
38
|
+
}
|