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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ondeckllm",
3
- "version": "1.1.0",
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": ["llm", "ai", "dashboard", "model-routing", "openai", "anthropic", "ollama", "ondeckllm"],
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
+ }