ondeckllm 1.1.0 → 1.4.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/package.json +11 -2
- package/src/cost-tracker.js +320 -0
- package/src/public/app.js +1189 -80
- package/src/public/index.html +19 -3
- package/src/public/styles.css +858 -0
- package/src/server.js +665 -27
- package/src/storage.js +109 -147
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ondeckllm",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Localhost dashboard for managing LLM providers, model routing, and batting-order fallback chains",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/server.js",
|
|
@@ -11,7 +11,16 @@
|
|
|
11
11
|
"start": "node src/server.js",
|
|
12
12
|
"dev": "node src/server.js"
|
|
13
13
|
},
|
|
14
|
-
"keywords": [
|
|
14
|
+
"keywords": [
|
|
15
|
+
"llm",
|
|
16
|
+
"ai",
|
|
17
|
+
"dashboard",
|
|
18
|
+
"model-routing",
|
|
19
|
+
"openai",
|
|
20
|
+
"anthropic",
|
|
21
|
+
"ollama",
|
|
22
|
+
"ondeckllm"
|
|
23
|
+
],
|
|
15
24
|
"author": "Canonflip",
|
|
16
25
|
"license": "MIT",
|
|
17
26
|
"dependencies": {
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { createReadStream } from 'fs';
|
|
5
|
+
import { createInterface } from 'readline';
|
|
6
|
+
|
|
7
|
+
const DATA_DIR = join(homedir(), '.ondeckllm');
|
|
8
|
+
const USAGE_FILE = join(DATA_DIR, 'usage.jsonl');
|
|
9
|
+
const OPENCLAW_SESSIONS_DIR = join(homedir(), '.openclaw', 'sessions');
|
|
10
|
+
const IMPORT_MARKER = join(DATA_DIR, '.openclaw-imported');
|
|
11
|
+
|
|
12
|
+
function ensureDataDir() {
|
|
13
|
+
if (!existsSync(DATA_DIR)) {
|
|
14
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Pricing (per 1M tokens) ──
|
|
19
|
+
|
|
20
|
+
const PRICING = {
|
|
21
|
+
// OpenAI
|
|
22
|
+
'gpt-4o': { input: 2.50, output: 10.00 },
|
|
23
|
+
'gpt-4o-mini': { input: 0.15, output: 0.60 },
|
|
24
|
+
'o3': { input: 10.00, output: 40.00 },
|
|
25
|
+
'o4-mini': { input: 1.10, output: 4.40 },
|
|
26
|
+
// Anthropic
|
|
27
|
+
'claude-opus-4-6': { input: 15.00, output: 75.00 },
|
|
28
|
+
'claude-sonnet-4-5-20250929': { input: 3.00, output: 15.00 },
|
|
29
|
+
'claude-haiku-4-5-20251001': { input: 0.80, output: 4.00 },
|
|
30
|
+
// Google
|
|
31
|
+
'gemini-2.0-pro': { input: 1.25, output: 5.00 },
|
|
32
|
+
'gemini-2.0-flash': { input: 0.10, output: 0.40 },
|
|
33
|
+
'gemini-1.5-flash': { input: 0.075, output: 0.30 },
|
|
34
|
+
// Groq
|
|
35
|
+
'llama-3.3-70b-versatile': { input: 0.59, output: 0.79 },
|
|
36
|
+
'llama-3.1-8b-instant': { input: 0.05, output: 0.08 },
|
|
37
|
+
'mixtral-8x7b-32768': { input: 0.24, output: 0.24 },
|
|
38
|
+
'gemma2-9b-it': { input: 0.20, output: 0.20 },
|
|
39
|
+
// Mistral
|
|
40
|
+
'mistral-large-latest': { input: 2.00, output: 6.00 },
|
|
41
|
+
'mistral-medium-latest': { input: 2.50, output: 7.50 },
|
|
42
|
+
'mistral-small-latest': { input: 0.20, output: 0.60 },
|
|
43
|
+
'codestral-latest': { input: 0.30, output: 0.90 },
|
|
44
|
+
// DeepSeek
|
|
45
|
+
'deepseek-chat': { input: 0.27, output: 1.10 },
|
|
46
|
+
'deepseek-coder': { input: 0.14, output: 0.28 },
|
|
47
|
+
'deepseek-reasoner': { input: 0.55, output: 2.19 },
|
|
48
|
+
// Together
|
|
49
|
+
'meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo': { input: 3.50, output: 3.50 },
|
|
50
|
+
'meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo': { input: 0.88, output: 0.88 },
|
|
51
|
+
// OpenRouter (varies, use rough estimates)
|
|
52
|
+
'openai/gpt-4o': { input: 2.50, output: 10.00 },
|
|
53
|
+
'anthropic/claude-opus-4-6': { input: 15.00, output: 75.00 },
|
|
54
|
+
'google/gemini-2.0-pro': { input: 1.25, output: 5.00 },
|
|
55
|
+
// Ollama (free/local)
|
|
56
|
+
// Default fallback for unknown models
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export function calculateCost(model, inputTokens, outputTokens) {
|
|
60
|
+
const pricing = PRICING[model];
|
|
61
|
+
if (!pricing) return 0; // local/unknown = free
|
|
62
|
+
return (inputTokens * pricing.input + outputTokens * pricing.output) / 1_000_000;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getPricing() {
|
|
66
|
+
return PRICING;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Log a usage entry ──
|
|
70
|
+
|
|
71
|
+
export function logUsage(entry) {
|
|
72
|
+
ensureDataDir();
|
|
73
|
+
const record = {
|
|
74
|
+
ts: entry.ts || Date.now(),
|
|
75
|
+
provider: entry.provider,
|
|
76
|
+
model: entry.model,
|
|
77
|
+
inputTokens: entry.inputTokens || 0,
|
|
78
|
+
outputTokens: entry.outputTokens || 0,
|
|
79
|
+
cost: entry.cost ?? calculateCost(entry.model, entry.inputTokens || 0, entry.outputTokens || 0),
|
|
80
|
+
latencyMs: entry.latencyMs || 0
|
|
81
|
+
};
|
|
82
|
+
appendFileSync(USAGE_FILE, JSON.stringify(record) + '\n');
|
|
83
|
+
return record;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Read all entries ──
|
|
87
|
+
|
|
88
|
+
function readAllEntries() {
|
|
89
|
+
ensureDataDir();
|
|
90
|
+
if (!existsSync(USAGE_FILE)) return [];
|
|
91
|
+
try {
|
|
92
|
+
const raw = readFileSync(USAGE_FILE, 'utf-8');
|
|
93
|
+
return raw.trim().split('\n').filter(Boolean).map(line => {
|
|
94
|
+
try { return JSON.parse(line); } catch { return null; }
|
|
95
|
+
}).filter(Boolean);
|
|
96
|
+
} catch { return []; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ── Raw entries (most recent N) ──
|
|
100
|
+
|
|
101
|
+
export function getRawEntries(limit = 100) {
|
|
102
|
+
const entries = readAllEntries();
|
|
103
|
+
return entries.slice(-limit).reverse();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── Summary with aggregation ──
|
|
107
|
+
|
|
108
|
+
export function getUsageSummary(range = 'all') {
|
|
109
|
+
const entries = readAllEntries();
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
|
|
112
|
+
// Time ranges
|
|
113
|
+
const startOfDay = new Date(); startOfDay.setHours(0,0,0,0);
|
|
114
|
+
const startOfWeek = new Date(); startOfWeek.setDate(startOfWeek.getDate() - startOfWeek.getDay()); startOfWeek.setHours(0,0,0,0);
|
|
115
|
+
const startOfMonth = new Date(); startOfMonth.setDate(1); startOfMonth.setHours(0,0,0,0);
|
|
116
|
+
|
|
117
|
+
const rangeStart = {
|
|
118
|
+
today: startOfDay.getTime(),
|
|
119
|
+
week: startOfWeek.getTime(),
|
|
120
|
+
month: startOfMonth.getTime(),
|
|
121
|
+
all: 0
|
|
122
|
+
}[range] || 0;
|
|
123
|
+
|
|
124
|
+
const filtered = entries.filter(e => e.ts >= rangeStart);
|
|
125
|
+
|
|
126
|
+
// Totals
|
|
127
|
+
let totalCost = 0, totalInputTokens = 0, totalOutputTokens = 0, totalRequests = 0;
|
|
128
|
+
const byProvider = {};
|
|
129
|
+
const byModel = {};
|
|
130
|
+
|
|
131
|
+
for (const e of filtered) {
|
|
132
|
+
totalCost += e.cost || 0;
|
|
133
|
+
totalInputTokens += e.inputTokens || 0;
|
|
134
|
+
totalOutputTokens += e.outputTokens || 0;
|
|
135
|
+
totalRequests++;
|
|
136
|
+
|
|
137
|
+
// By provider
|
|
138
|
+
if (!byProvider[e.provider]) {
|
|
139
|
+
byProvider[e.provider] = { cost: 0, inputTokens: 0, outputTokens: 0, requests: 0, totalLatency: 0 };
|
|
140
|
+
}
|
|
141
|
+
byProvider[e.provider].cost += e.cost || 0;
|
|
142
|
+
byProvider[e.provider].inputTokens += e.inputTokens || 0;
|
|
143
|
+
byProvider[e.provider].outputTokens += e.outputTokens || 0;
|
|
144
|
+
byProvider[e.provider].requests++;
|
|
145
|
+
byProvider[e.provider].totalLatency += e.latencyMs || 0;
|
|
146
|
+
|
|
147
|
+
// By model
|
|
148
|
+
if (!byModel[e.model]) {
|
|
149
|
+
byModel[e.model] = { cost: 0, inputTokens: 0, outputTokens: 0, requests: 0 };
|
|
150
|
+
}
|
|
151
|
+
byModel[e.model].cost += e.cost || 0;
|
|
152
|
+
byModel[e.model].inputTokens += e.inputTokens || 0;
|
|
153
|
+
byModel[e.model].outputTokens += e.outputTokens || 0;
|
|
154
|
+
byModel[e.model].requests++;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Compute avg latency per provider
|
|
158
|
+
for (const [, v] of Object.entries(byProvider)) {
|
|
159
|
+
v.avgLatency = v.requests > 0 ? Math.round(v.totalLatency / v.requests) : 0;
|
|
160
|
+
delete v.totalLatency;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Daily timeseries (last 30 days)
|
|
164
|
+
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
|
165
|
+
const dailyMap = {};
|
|
166
|
+
for (let i = 29; i >= 0; i--) {
|
|
167
|
+
const d = new Date(now - i * 24 * 60 * 60 * 1000);
|
|
168
|
+
const key = d.toISOString().slice(0, 10);
|
|
169
|
+
dailyMap[key] = { date: key, cost: 0, requests: 0 };
|
|
170
|
+
}
|
|
171
|
+
for (const e of entries) {
|
|
172
|
+
if (e.ts < thirtyDaysAgo) continue;
|
|
173
|
+
const key = new Date(e.ts).toISOString().slice(0, 10);
|
|
174
|
+
if (dailyMap[key]) {
|
|
175
|
+
dailyMap[key].cost += e.cost || 0;
|
|
176
|
+
dailyMap[key].requests++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const daily = Object.values(dailyMap);
|
|
180
|
+
|
|
181
|
+
// Per-range totals for summary cards
|
|
182
|
+
const todayEntries = entries.filter(e => e.ts >= startOfDay.getTime());
|
|
183
|
+
const weekEntries = entries.filter(e => e.ts >= startOfWeek.getTime());
|
|
184
|
+
const monthEntries = entries.filter(e => e.ts >= startOfMonth.getTime());
|
|
185
|
+
|
|
186
|
+
const todayCost = todayEntries.reduce((s, e) => s + (e.cost || 0), 0);
|
|
187
|
+
const weekCost = weekEntries.reduce((s, e) => s + (e.cost || 0), 0);
|
|
188
|
+
const monthCost = monthEntries.reduce((s, e) => s + (e.cost || 0), 0);
|
|
189
|
+
const allCost = entries.reduce((s, e) => s + (e.cost || 0), 0);
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
range,
|
|
193
|
+
totalCost, totalInputTokens, totalOutputTokens, totalRequests,
|
|
194
|
+
todayCost, weekCost, monthCost, allCost,
|
|
195
|
+
byProvider, byModel, daily
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── OpenClaw Session Import (Continuous) ──
|
|
200
|
+
|
|
201
|
+
const SYNC_STATE_FILE = join(DATA_DIR, 'openclaw-sync-state.json');
|
|
202
|
+
const OPENCLAW_AGENTS_DIR = join(homedir(), '.openclaw', 'agents');
|
|
203
|
+
|
|
204
|
+
function loadSyncState() {
|
|
205
|
+
ensureDataDir();
|
|
206
|
+
if (!existsSync(SYNC_STATE_FILE)) return { files: {} };
|
|
207
|
+
try {
|
|
208
|
+
return JSON.parse(readFileSync(SYNC_STATE_FILE, 'utf-8'));
|
|
209
|
+
} catch { return { files: {} }; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function saveSyncState(state) {
|
|
213
|
+
ensureDataDir();
|
|
214
|
+
writeFileSync(SYNC_STATE_FILE, JSON.stringify(state));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export async function importOpenClawSessions() {
|
|
218
|
+
ensureDataDir();
|
|
219
|
+
const state = loadSyncState();
|
|
220
|
+
let imported = 0;
|
|
221
|
+
let filesScanned = 0;
|
|
222
|
+
|
|
223
|
+
// Scan all agent session dirs
|
|
224
|
+
const agentDirs = [];
|
|
225
|
+
try {
|
|
226
|
+
if (existsSync(OPENCLAW_AGENTS_DIR)) {
|
|
227
|
+
for (const agent of readdirSync(OPENCLAW_AGENTS_DIR)) {
|
|
228
|
+
const sessDir = join(OPENCLAW_AGENTS_DIR, agent, 'sessions');
|
|
229
|
+
if (existsSync(sessDir)) agentDirs.push(sessDir);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch { /* ignore */ }
|
|
233
|
+
|
|
234
|
+
// Also check legacy path
|
|
235
|
+
if (existsSync(OPENCLAW_SESSIONS_DIR)) agentDirs.push(OPENCLAW_SESSIONS_DIR);
|
|
236
|
+
|
|
237
|
+
for (const sessDir of agentDirs) {
|
|
238
|
+
let files;
|
|
239
|
+
try { files = readdirSync(sessDir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
|
|
240
|
+
|
|
241
|
+
for (const file of files) {
|
|
242
|
+
const filePath = join(sessDir, file);
|
|
243
|
+
filesScanned++;
|
|
244
|
+
|
|
245
|
+
// Get file size to detect changes
|
|
246
|
+
let fileSize;
|
|
247
|
+
try {
|
|
248
|
+
const { statSync } = await import('fs');
|
|
249
|
+
fileSize = statSync(filePath).size;
|
|
250
|
+
} catch { continue; }
|
|
251
|
+
|
|
252
|
+
const prevOffset = state.files[filePath]?.offset || 0;
|
|
253
|
+
if (fileSize <= prevOffset) continue; // No new data
|
|
254
|
+
|
|
255
|
+
// Read only new bytes
|
|
256
|
+
try {
|
|
257
|
+
const fd = (await import('fs')).openSync(filePath, 'r');
|
|
258
|
+
const buf = Buffer.alloc(fileSize - prevOffset);
|
|
259
|
+
(await import('fs')).readSync(fd, buf, 0, buf.length, prevOffset);
|
|
260
|
+
(await import('fs')).closeSync(fd);
|
|
261
|
+
|
|
262
|
+
const newData = buf.toString('utf-8');
|
|
263
|
+
const lines = newData.split('\n');
|
|
264
|
+
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
if (!line.trim()) continue;
|
|
267
|
+
try {
|
|
268
|
+
const data = JSON.parse(line);
|
|
269
|
+
// Only process assistant messages with usage data
|
|
270
|
+
if (data.type !== 'message') continue;
|
|
271
|
+
const msg = data.message;
|
|
272
|
+
if (!msg || msg.role !== 'assistant' || !msg.usage) continue;
|
|
273
|
+
|
|
274
|
+
const usage = msg.usage;
|
|
275
|
+
const inputTokens = usage.input || usage.input_tokens || 0;
|
|
276
|
+
const outputTokens = usage.output || usage.output_tokens || 0;
|
|
277
|
+
const cacheRead = usage.cacheRead || 0;
|
|
278
|
+
const cacheWrite = usage.cacheWrite || 0;
|
|
279
|
+
|
|
280
|
+
if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && cacheWrite === 0) continue;
|
|
281
|
+
|
|
282
|
+
const model = msg.model || 'unknown';
|
|
283
|
+
const provider = msg.provider || guessProvider(model);
|
|
284
|
+
const cost = usage.cost?.total ?? calculateCost(model, inputTokens, outputTokens);
|
|
285
|
+
const ts = data.timestamp ? new Date(data.timestamp).getTime() : Date.now();
|
|
286
|
+
|
|
287
|
+
logUsage({
|
|
288
|
+
ts,
|
|
289
|
+
provider,
|
|
290
|
+
model,
|
|
291
|
+
inputTokens: inputTokens + cacheRead + cacheWrite,
|
|
292
|
+
outputTokens,
|
|
293
|
+
cost,
|
|
294
|
+
latencyMs: 0,
|
|
295
|
+
source: 'openclaw'
|
|
296
|
+
});
|
|
297
|
+
imported++;
|
|
298
|
+
} catch { /* skip unparseable lines */ }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
state.files[filePath] = { offset: fileSize, lastSync: Date.now() };
|
|
302
|
+
} catch { /* skip unreadable files */ }
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
saveSyncState(state);
|
|
307
|
+
return { imported, filesScanned, totalTracked: Object.keys(state.files).length };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function guessProvider(model) {
|
|
311
|
+
if (!model) return 'unknown';
|
|
312
|
+
const m = model.toLowerCase();
|
|
313
|
+
if (m.includes('claude') || m.includes('anthropic')) return 'anthropic';
|
|
314
|
+
if (m.includes('gpt') || m.includes('o3') || m.includes('o4') || m.includes('dall-e')) return 'openai';
|
|
315
|
+
if (m.includes('gemini') || m.includes('imagen')) return 'google';
|
|
316
|
+
if (m.includes('llama') || m.includes('mixtral') || m.includes('gemma')) return 'groq';
|
|
317
|
+
if (m.includes('mistral') || m.includes('codestral')) return 'mistral';
|
|
318
|
+
if (m.includes('deepseek')) return 'deepseek';
|
|
319
|
+
return 'unknown';
|
|
320
|
+
}
|