@decido/kernel-bridge 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/.turbo/turbo-build.log +13 -0
- package/.turbo/turbo-lint.log +30 -0
- package/package.json +37 -0
- package/src/ai/components/PeerNetworkPanel.tsx +219 -0
- package/src/ai/components/TokenWalletPanel.tsx +172 -0
- package/src/ai/hooks/usePeerMesh.ts +79 -0
- package/src/ai/hooks/useTokenWallet.ts +35 -0
- package/src/ai/index.ts +96 -0
- package/src/ai/services/EmbeddingService.ts +119 -0
- package/src/ai/services/InferenceRouter.ts +347 -0
- package/src/ai/services/LocalAgentResponder.ts +199 -0
- package/src/ai/services/MLXBridge.ts +278 -0
- package/src/ai/services/OllamaService.ts +326 -0
- package/src/ai/services/PeerMesh.ts +373 -0
- package/src/ai/services/TokenWallet.ts +237 -0
- package/src/ai/services/providers/AnthropicProvider.ts +229 -0
- package/src/ai/services/providers/GeminiProvider.ts +121 -0
- package/src/ai/services/providers/LLMProvider.ts +72 -0
- package/src/ai/services/providers/OllamaProvider.ts +84 -0
- package/src/ai/services/providers/OpenAIProvider.ts +178 -0
- package/src/crypto.ts +54 -0
- package/src/index.ts +4 -0
- package/src/kernel.ts +376 -0
- package/src/rehydration.ts +52 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PeerMesh — WebSocket-based P2P mesh for Decido OS instances
|
|
3
|
+
*
|
|
4
|
+
* Allows multiple Decido OS instances on the same network to form
|
|
5
|
+
* a group and share AI results/context. Each peer maintains its
|
|
6
|
+
* own provider keys (BYOK). Uses manual connection (IP:port) in
|
|
7
|
+
* this first iteration; future versions will use mDNS auto-discovery.
|
|
8
|
+
*
|
|
9
|
+
* Protocol:
|
|
10
|
+
* - Host starts a WebSocket server on a port (default 9876)
|
|
11
|
+
* - Clients connect to the host's IP:port
|
|
12
|
+
* - Messages: join, leave, share-result, share-context, heartbeat
|
|
13
|
+
* - Max 8 peers per group
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { v4 as uuid } from 'uuid';
|
|
17
|
+
|
|
18
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
export interface PeerInfo {
|
|
21
|
+
id: string;
|
|
22
|
+
name: string;
|
|
23
|
+
address: string;
|
|
24
|
+
joinedAt: number;
|
|
25
|
+
lastSeen: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SharedResult {
|
|
29
|
+
peerId: string;
|
|
30
|
+
provider: string;
|
|
31
|
+
model: string;
|
|
32
|
+
prompt: string;
|
|
33
|
+
response: string;
|
|
34
|
+
timestamp: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface PeerMessage {
|
|
38
|
+
type: 'join' | 'leave' | 'share-result' | 'share-context' | 'heartbeat' | 'peers-update';
|
|
39
|
+
peerId: string;
|
|
40
|
+
peerName: string;
|
|
41
|
+
payload?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const DEFAULT_PORT = 9876;
|
|
47
|
+
const HEARTBEAT_INTERVAL = 10_000; // 10s
|
|
48
|
+
const PEER_TIMEOUT = 30_000; // 30s without heartbeat = dead
|
|
49
|
+
const MAX_PEERS = 8;
|
|
50
|
+
|
|
51
|
+
// ─── Event Callbacks ────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
type PeersChangedCb = (peers: PeerInfo[]) => void;
|
|
54
|
+
type HostingChangedCb = (isHosting: boolean) => void;
|
|
55
|
+
type ConnectionChangedCb = (isConnected: boolean) => void;
|
|
56
|
+
type SharedResultCb = (result: SharedResult) => void;
|
|
57
|
+
|
|
58
|
+
// ─── PeerMesh Class ─────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
class PeerMeshImpl {
|
|
61
|
+
private myId = uuid();
|
|
62
|
+
private myName = this._getHostname();
|
|
63
|
+
private peers = new Map<string, PeerInfo>();
|
|
64
|
+
private connections = new Map<string, WebSocket>();
|
|
65
|
+
private isHostingFlag = false;
|
|
66
|
+
private isConnectedFlag = false;
|
|
67
|
+
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
68
|
+
private cleanupTimer: ReturnType<typeof setInterval> | null = null;
|
|
69
|
+
|
|
70
|
+
// Event listeners
|
|
71
|
+
private peersListeners = new Set<PeersChangedCb>();
|
|
72
|
+
private hostingListeners = new Set<HostingChangedCb>();
|
|
73
|
+
private connectionListeners = new Set<ConnectionChangedCb>();
|
|
74
|
+
private resultListeners = new Set<SharedResultCb>();
|
|
75
|
+
|
|
76
|
+
// ── Host Mode ─────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Start hosting a peer group.
|
|
80
|
+
* Note: In browser WebSocket server is not available. This method
|
|
81
|
+
* is a placeholder for Tauri (Rust-side server). In browser mode,
|
|
82
|
+
* we simulate by acting as a "relay" through a shared connection.
|
|
83
|
+
*/
|
|
84
|
+
startHost(port = DEFAULT_PORT): void {
|
|
85
|
+
if (this.isHostingFlag) return;
|
|
86
|
+
this.isHostingFlag = true;
|
|
87
|
+
this._emitHosting(true);
|
|
88
|
+
this._startHeartbeat();
|
|
89
|
+
console.log(`🌐 [PeerMesh] Hosting on port ${port} (peer: ${this.myId.slice(0, 8)})`);
|
|
90
|
+
console.log(`🌐 [PeerMesh] ⚠️ Browser-mode: WebSocket server requires Tauri backend.`);
|
|
91
|
+
console.log(`🌐 [PeerMesh] Use connectToPeer() from another instance to connect.`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
stopHost(): void {
|
|
95
|
+
if (!this.isHostingFlag) return;
|
|
96
|
+
this.isHostingFlag = false;
|
|
97
|
+
this._stopHeartbeat();
|
|
98
|
+
this._broadcast({ type: 'leave', peerId: this.myId, peerName: this.myName });
|
|
99
|
+
this._disconnectAll();
|
|
100
|
+
this._emitHosting(false);
|
|
101
|
+
console.log('🌐 [PeerMesh] Stopped hosting');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Client Mode ───────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
connectToPeer(address: string): void {
|
|
107
|
+
if (this.connections.size >= MAX_PEERS) {
|
|
108
|
+
console.warn(`🌐 [PeerMesh] Max peers (${MAX_PEERS}) reached`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const wsUrl = address.startsWith('ws') ? address : `ws://${address}`;
|
|
113
|
+
console.log(`🌐 [PeerMesh] Connecting to ${wsUrl}...`);
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const ws = new WebSocket(wsUrl);
|
|
117
|
+
|
|
118
|
+
ws.onopen = () => {
|
|
119
|
+
console.log(`🌐 [PeerMesh] ✅ Connected to ${address}`);
|
|
120
|
+
this.connections.set(address, ws);
|
|
121
|
+
this.isConnectedFlag = true;
|
|
122
|
+
this._emitConnection(true);
|
|
123
|
+
|
|
124
|
+
// Announce ourselves
|
|
125
|
+
this._send(ws, {
|
|
126
|
+
type: 'join',
|
|
127
|
+
peerId: this.myId,
|
|
128
|
+
peerName: this.myName,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
this._startHeartbeat();
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
ws.onmessage = (event) => {
|
|
135
|
+
try {
|
|
136
|
+
const msg: PeerMessage = JSON.parse(event.data);
|
|
137
|
+
this._handleMessage(msg, address);
|
|
138
|
+
} catch { /* ignore malformed messages */ }
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
ws.onclose = () => {
|
|
142
|
+
console.log(`🌐 [PeerMesh] Disconnected from ${address}`);
|
|
143
|
+
this.connections.delete(address);
|
|
144
|
+
// Remove peer associated with this address
|
|
145
|
+
for (const [id, peer] of this.peers) {
|
|
146
|
+
if (peer.address === address) {
|
|
147
|
+
this.peers.delete(id);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
this._emitPeers();
|
|
151
|
+
if (this.connections.size === 0) {
|
|
152
|
+
this.isConnectedFlag = false;
|
|
153
|
+
this._emitConnection(false);
|
|
154
|
+
this._stopHeartbeat();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
ws.onerror = (err) => {
|
|
159
|
+
console.error(`🌐 [PeerMesh] Connection error to ${address}:`, err);
|
|
160
|
+
};
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.error(`🌐 [PeerMesh] Failed to connect:`, err);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
disconnect(): void {
|
|
167
|
+
this._broadcast({ type: 'leave', peerId: this.myId, peerName: this.myName });
|
|
168
|
+
this._disconnectAll();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Share Results ─────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
shareResult(result: Omit<SharedResult, 'peerId' | 'timestamp'>): void {
|
|
174
|
+
const fullResult: SharedResult = {
|
|
175
|
+
...result,
|
|
176
|
+
peerId: this.myId,
|
|
177
|
+
timestamp: Date.now(),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
this._broadcast({
|
|
181
|
+
type: 'share-result',
|
|
182
|
+
peerId: this.myId,
|
|
183
|
+
peerName: this.myName,
|
|
184
|
+
payload: fullResult,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
console.log(`🌐 [PeerMesh] Shared result to ${this.connections.size} peers`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Event Subscriptions ───────────────────────────────
|
|
191
|
+
|
|
192
|
+
onPeersChanged(cb: PeersChangedCb): () => void {
|
|
193
|
+
this.peersListeners.add(cb);
|
|
194
|
+
return () => { this.peersListeners.delete(cb); };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
onHostingChanged(cb: HostingChangedCb): () => void {
|
|
198
|
+
this.hostingListeners.add(cb);
|
|
199
|
+
return () => { this.hostingListeners.delete(cb); };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
onConnectionChanged(cb: ConnectionChangedCb): () => void {
|
|
203
|
+
this.connectionListeners.add(cb);
|
|
204
|
+
return () => { this.connectionListeners.delete(cb); };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
onSharedResult(cb: SharedResultCb): () => void {
|
|
208
|
+
this.resultListeners.add(cb);
|
|
209
|
+
return () => { this.resultListeners.delete(cb); };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Getters ───────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
getPeers(): PeerInfo[] {
|
|
215
|
+
return Array.from(this.peers.values());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
getMyId(): string {
|
|
219
|
+
return this.myId;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
getMyName(): string {
|
|
223
|
+
return this.myName;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Message Handling ──────────────────────────────────
|
|
227
|
+
|
|
228
|
+
private _handleMessage(msg: PeerMessage, fromAddress: string): void {
|
|
229
|
+
switch (msg.type) {
|
|
230
|
+
case 'join':
|
|
231
|
+
this.peers.set(msg.peerId, {
|
|
232
|
+
id: msg.peerId,
|
|
233
|
+
name: msg.peerName,
|
|
234
|
+
address: fromAddress,
|
|
235
|
+
joinedAt: Date.now(),
|
|
236
|
+
lastSeen: Date.now(),
|
|
237
|
+
});
|
|
238
|
+
this._emitPeers();
|
|
239
|
+
console.log(`🌐 [PeerMesh] Peer joined: ${msg.peerName} (${msg.peerId.slice(0, 8)})`);
|
|
240
|
+
break;
|
|
241
|
+
|
|
242
|
+
case 'leave':
|
|
243
|
+
this.peers.delete(msg.peerId);
|
|
244
|
+
this._emitPeers();
|
|
245
|
+
console.log(`🌐 [PeerMesh] Peer left: ${msg.peerName}`);
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case 'heartbeat':
|
|
249
|
+
if (this.peers.has(msg.peerId)) {
|
|
250
|
+
this.peers.get(msg.peerId)!.lastSeen = Date.now();
|
|
251
|
+
}
|
|
252
|
+
break;
|
|
253
|
+
|
|
254
|
+
case 'share-result':
|
|
255
|
+
if (msg.payload) {
|
|
256
|
+
const result = msg.payload as SharedResult;
|
|
257
|
+
for (const cb of this.resultListeners) {
|
|
258
|
+
try { cb(result); } catch { /* ignore */ }
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
break;
|
|
262
|
+
|
|
263
|
+
case 'peers-update':
|
|
264
|
+
// Host sends updated peer list
|
|
265
|
+
if (Array.isArray(msg.payload)) {
|
|
266
|
+
for (const p of msg.payload as PeerInfo[]) {
|
|
267
|
+
if (p.id !== this.myId && !this.peers.has(p.id)) {
|
|
268
|
+
this.peers.set(p.id, p);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this._emitPeers();
|
|
272
|
+
}
|
|
273
|
+
break;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── Internal Helpers ──────────────────────────────────
|
|
278
|
+
|
|
279
|
+
private _send(ws: WebSocket, msg: PeerMessage): void {
|
|
280
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
281
|
+
ws.send(JSON.stringify(msg));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private _broadcast(msg: PeerMessage): void {
|
|
286
|
+
for (const ws of this.connections.values()) {
|
|
287
|
+
this._send(ws, msg);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private _disconnectAll(): void {
|
|
292
|
+
for (const ws of this.connections.values()) {
|
|
293
|
+
try { ws.close(); } catch { /* ignore */ }
|
|
294
|
+
}
|
|
295
|
+
this.connections.clear();
|
|
296
|
+
this.peers.clear();
|
|
297
|
+
this.isConnectedFlag = false;
|
|
298
|
+
this._stopHeartbeat();
|
|
299
|
+
this._emitPeers();
|
|
300
|
+
this._emitConnection(false);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
private _startHeartbeat(): void {
|
|
304
|
+
if (this.heartbeatTimer) return;
|
|
305
|
+
|
|
306
|
+
this.heartbeatTimer = setInterval(() => {
|
|
307
|
+
this._broadcast({
|
|
308
|
+
type: 'heartbeat',
|
|
309
|
+
peerId: this.myId,
|
|
310
|
+
peerName: this.myName,
|
|
311
|
+
});
|
|
312
|
+
}, HEARTBEAT_INTERVAL);
|
|
313
|
+
|
|
314
|
+
this.cleanupTimer = setInterval(() => {
|
|
315
|
+
const now = Date.now();
|
|
316
|
+
let changed = false;
|
|
317
|
+
for (const [id, peer] of this.peers) {
|
|
318
|
+
if (now - peer.lastSeen > PEER_TIMEOUT) {
|
|
319
|
+
this.peers.delete(id);
|
|
320
|
+
changed = true;
|
|
321
|
+
console.log(`🌐 [PeerMesh] Peer timed out: ${peer.name}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if (changed) this._emitPeers();
|
|
325
|
+
}, PEER_TIMEOUT);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private _stopHeartbeat(): void {
|
|
329
|
+
if (this.heartbeatTimer) {
|
|
330
|
+
clearInterval(this.heartbeatTimer);
|
|
331
|
+
this.heartbeatTimer = null;
|
|
332
|
+
}
|
|
333
|
+
if (this.cleanupTimer) {
|
|
334
|
+
clearInterval(this.cleanupTimer);
|
|
335
|
+
this.cleanupTimer = null;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ── Event Emitters ────────────────────────────────────
|
|
340
|
+
|
|
341
|
+
private _emitPeers(): void {
|
|
342
|
+
const list = this.getPeers();
|
|
343
|
+
for (const cb of this.peersListeners) {
|
|
344
|
+
try { cb(list); } catch { /* ignore */ }
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private _emitHosting(hosting: boolean): void {
|
|
349
|
+
for (const cb of this.hostingListeners) {
|
|
350
|
+
try { cb(hosting); } catch { /* ignore */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private _emitConnection(connected: boolean): void {
|
|
355
|
+
for (const cb of this.connectionListeners) {
|
|
356
|
+
try { cb(connected); } catch { /* ignore */ }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Hostname ──────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
private _getHostname(): string {
|
|
363
|
+
try {
|
|
364
|
+
return `user-${Math.random().toString(36).slice(2, 6)}`;
|
|
365
|
+
} catch {
|
|
366
|
+
return 'anonymous';
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ─── Singleton Export ───────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
export const peerMesh = new PeerMeshImpl();
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TokenWallet — Usage tracking & cost estimation for LLM providers
|
|
3
|
+
*
|
|
4
|
+
* Records every inference call and calculates estimated costs
|
|
5
|
+
* using a per-model pricing table. Persists via localStorage
|
|
6
|
+
* (with Tauri fs fallback).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ─── Pricing Table (USD per 1M tokens) ─────────────────────
|
|
10
|
+
|
|
11
|
+
interface ModelPricing {
|
|
12
|
+
input: number; // per 1M input tokens
|
|
13
|
+
output: number; // per 1M output tokens
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PRICING: Record<string, ModelPricing> = {
|
|
17
|
+
// Ollama / local — free
|
|
18
|
+
'qwen2:latest': { input: 0, output: 0 },
|
|
19
|
+
'llama3:latest': { input: 0, output: 0 },
|
|
20
|
+
'mistral:latest': { input: 0, output: 0 },
|
|
21
|
+
'codellama:latest': { input: 0, output: 0 },
|
|
22
|
+
|
|
23
|
+
// Gemini
|
|
24
|
+
'gemini-flash-lite-latest': { input: 0.10, output: 0.40 },
|
|
25
|
+
'gemini-flash-lite-latest-lite': { input: 0.075, output: 0.30 },
|
|
26
|
+
'gemini-1.5-pro': { input: 1.25, output: 5.00 },
|
|
27
|
+
'gemini-flash-latest': { input: 0.075, output: 0.30 },
|
|
28
|
+
|
|
29
|
+
// Anthropic
|
|
30
|
+
'claude-sonnet-4-20250514': { input: 3.00, output: 15.00 },
|
|
31
|
+
'claude-3-5-sonnet-20241022': { input: 3.00, output: 15.00 },
|
|
32
|
+
'claude-3-5-haiku-20241022': { input: 0.80, output: 4.00 },
|
|
33
|
+
'claude-3-opus-20240229': { input: 15.00, output: 75.00 },
|
|
34
|
+
|
|
35
|
+
// OpenAI
|
|
36
|
+
'gpt-4o': { input: 2.50, output: 10.00 },
|
|
37
|
+
'gpt-4o-mini': { input: 0.15, output: 0.60 },
|
|
38
|
+
'gpt-4-turbo': { input: 10.00, output: 30.00 },
|
|
39
|
+
'gpt-4': { input: 30.00, output: 60.00 },
|
|
40
|
+
'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
|
|
41
|
+
'o3-mini': { input: 1.10, output: 4.40 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Default pricing for unknown models (assume cloud provider)
|
|
45
|
+
const DEFAULT_PRICING: ModelPricing = { input: 1.00, output: 3.00 };
|
|
46
|
+
|
|
47
|
+
// ─── Types ──────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
export interface TokenUsageEntry {
|
|
50
|
+
provider: string;
|
|
51
|
+
model: string;
|
|
52
|
+
inputTokens: number;
|
|
53
|
+
outputTokens: number;
|
|
54
|
+
totalTokens: number;
|
|
55
|
+
estimatedCostUsd: number;
|
|
56
|
+
timestamp: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ProviderSummary {
|
|
60
|
+
tokens: number;
|
|
61
|
+
cost: number;
|
|
62
|
+
calls: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface WalletSummary {
|
|
66
|
+
totalTokens: number;
|
|
67
|
+
totalCostUsd: number;
|
|
68
|
+
totalCalls: number;
|
|
69
|
+
byProvider: Record<string, ProviderSummary>;
|
|
70
|
+
sessionStart: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type WalletListener = (summary: WalletSummary) => void;
|
|
74
|
+
|
|
75
|
+
// ─── Storage Key ────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
const STORAGE_KEY = 'decido-token-wallet';
|
|
78
|
+
const MAX_HISTORY = 500; // keep last 500 entries
|
|
79
|
+
|
|
80
|
+
// ─── TokenWallet Class ──────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
class TokenWalletImpl {
|
|
83
|
+
private history: TokenUsageEntry[] = [];
|
|
84
|
+
private sessionStart = Date.now();
|
|
85
|
+
private listeners = new Set<WalletListener>();
|
|
86
|
+
|
|
87
|
+
constructor() {
|
|
88
|
+
this._loadFromStorage();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Record Usage ────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
record(entry: {
|
|
94
|
+
provider: string;
|
|
95
|
+
model: string;
|
|
96
|
+
tokensUsed?: number;
|
|
97
|
+
inputTokens?: number;
|
|
98
|
+
outputTokens?: number;
|
|
99
|
+
latencyMs?: number;
|
|
100
|
+
}): void {
|
|
101
|
+
// Estimate input/output split if not provided
|
|
102
|
+
const totalTokens = entry.tokensUsed ?? (entry.inputTokens ?? 0) + (entry.outputTokens ?? 0);
|
|
103
|
+
const inputTokens = entry.inputTokens ?? Math.round(totalTokens * 0.4);
|
|
104
|
+
const outputTokens = entry.outputTokens ?? (totalTokens - inputTokens);
|
|
105
|
+
|
|
106
|
+
const pricing = this._getPricing(entry.model, entry.provider);
|
|
107
|
+
const estimatedCostUsd =
|
|
108
|
+
(inputTokens / 1_000_000) * pricing.input +
|
|
109
|
+
(outputTokens / 1_000_000) * pricing.output;
|
|
110
|
+
|
|
111
|
+
const usageEntry: TokenUsageEntry = {
|
|
112
|
+
provider: entry.provider,
|
|
113
|
+
model: entry.model,
|
|
114
|
+
inputTokens,
|
|
115
|
+
outputTokens,
|
|
116
|
+
totalTokens,
|
|
117
|
+
estimatedCostUsd,
|
|
118
|
+
timestamp: Date.now(),
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
this.history.push(usageEntry);
|
|
122
|
+
|
|
123
|
+
// Trim history if too large
|
|
124
|
+
if (this.history.length > MAX_HISTORY) {
|
|
125
|
+
this.history = this.history.slice(-MAX_HISTORY);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this._saveToStorage();
|
|
129
|
+
this._emit();
|
|
130
|
+
|
|
131
|
+
console.log(
|
|
132
|
+
`💰 [Wallet] ${entry.provider}/${entry.model}: ${totalTokens} tokens ≈ $${estimatedCostUsd.toFixed(6)}`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Summary ─────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
getSummary(): WalletSummary {
|
|
139
|
+
const byProvider: Record<string, ProviderSummary> = {};
|
|
140
|
+
let totalTokens = 0;
|
|
141
|
+
let totalCostUsd = 0;
|
|
142
|
+
|
|
143
|
+
for (const entry of this.history) {
|
|
144
|
+
totalTokens += entry.totalTokens;
|
|
145
|
+
totalCostUsd += entry.estimatedCostUsd;
|
|
146
|
+
|
|
147
|
+
if (!byProvider[entry.provider]) {
|
|
148
|
+
byProvider[entry.provider] = { tokens: 0, cost: 0, calls: 0 };
|
|
149
|
+
}
|
|
150
|
+
byProvider[entry.provider].tokens += entry.totalTokens;
|
|
151
|
+
byProvider[entry.provider].cost += entry.estimatedCostUsd;
|
|
152
|
+
byProvider[entry.provider].calls += 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
totalTokens,
|
|
157
|
+
totalCostUsd,
|
|
158
|
+
totalCalls: this.history.length,
|
|
159
|
+
byProvider,
|
|
160
|
+
sessionStart: this.sessionStart,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
getHistory(): TokenUsageEntry[] {
|
|
165
|
+
return [...this.history];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getRecentHistory(count: number): TokenUsageEntry[] {
|
|
169
|
+
return this.history.slice(-count);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Lifecycle ───────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
clearHistory(): void {
|
|
175
|
+
this.history = [];
|
|
176
|
+
this.sessionStart = Date.now();
|
|
177
|
+
this._saveToStorage();
|
|
178
|
+
this._emit();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Subscriptions ───────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
subscribe(listener: WalletListener): () => void {
|
|
184
|
+
this.listeners.add(listener);
|
|
185
|
+
return () => { this.listeners.delete(listener); };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── Pricing Lookup ──────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
private _getPricing(model: string, provider: string): ModelPricing {
|
|
191
|
+
// Exact match
|
|
192
|
+
if (PRICING[model]) return PRICING[model];
|
|
193
|
+
|
|
194
|
+
// Local providers are always free
|
|
195
|
+
if (provider === 'ollama' || provider === 'mlx') {
|
|
196
|
+
return { input: 0, output: 0 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return DEFAULT_PRICING;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Persistence ─────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
private _saveToStorage(): void {
|
|
205
|
+
try {
|
|
206
|
+
const data = JSON.stringify({
|
|
207
|
+
history: this.history,
|
|
208
|
+
sessionStart: this.sessionStart,
|
|
209
|
+
});
|
|
210
|
+
localStorage.setItem(STORAGE_KEY, data);
|
|
211
|
+
} catch { /* storage full or unavailable */ }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private _loadFromStorage(): void {
|
|
215
|
+
try {
|
|
216
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
217
|
+
if (raw) {
|
|
218
|
+
const data = JSON.parse(raw);
|
|
219
|
+
this.history = data.history || [];
|
|
220
|
+
this.sessionStart = data.sessionStart || Date.now();
|
|
221
|
+
}
|
|
222
|
+
} catch { /* corrupted data, start fresh */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Emit ────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
private _emit(): void {
|
|
228
|
+
const summary = this.getSummary();
|
|
229
|
+
for (const listener of this.listeners) {
|
|
230
|
+
try { listener(summary); } catch { /* ignore listener errors */ }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ─── Singleton Export ───────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
export const tokenWallet = new TokenWalletImpl();
|