@vimoxshah/tokenflow 1.1.0 → 1.1.1
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/CHANGELOG.md +84 -0
- package/README.md +50 -1
- package/bin/tokenflow.js +222 -9
- package/package.json +2 -1
- package/src/analytics/anomalies.js +17 -0
- package/src/commands/diagnostics.js +14 -1
- package/src/core/live-status.js +14 -3
- package/src/core/store.js +51 -0
- package/src/core/sync.js +9 -1
- package/src/core/team.js +162 -0
- package/src/core/watch-agent.js +226 -0
- package/src/core/watch-lock.js +160 -0
- package/src/core/watch.js +29 -40
- package/src/providers/hermes/index.js +41 -4
- package/src/server/server.js +29 -0
package/src/core/watch.js
CHANGED
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Design constraints this file takes seriously:
|
|
10
10
|
*
|
|
11
|
-
* Single instance.
|
|
12
|
-
* store
|
|
11
|
+
* Single instance. An identity-bearing lock guards against two watchers
|
|
12
|
+
* racing on the same store. It records the boot the pid was issued by, so a
|
|
13
|
+
* lock that outlived a reboot is stale by construction rather than being
|
|
14
|
+
* trusted because the kernel handed its number to something else
|
|
15
|
+
* (see watch-lock.js).
|
|
13
16
|
* Failure isolation. One bad provider cannot stop the loop: refresh errors
|
|
14
17
|
* are recorded into the status file and back off exponentially instead.
|
|
15
18
|
* Sleep/wake. The loop reschedules from wall-clock reality every tick, so a
|
|
@@ -18,12 +21,14 @@
|
|
|
18
21
|
* Nothing leaves the machine. Refresh reads local logs; notifications go to
|
|
19
22
|
* the local OS; the status file stays in $TOKENFLOW_HOME.
|
|
20
23
|
*/
|
|
21
|
-
import
|
|
22
|
-
import { loadConfig, paths, ensureDirs } from './config.js';
|
|
24
|
+
import { loadConfig, ensureDirs } from './config.js';
|
|
23
25
|
import { refresh } from './ingest.js';
|
|
24
26
|
import { listProviders } from './registry.js';
|
|
25
27
|
import { buildLiveStatus, writeLiveStatus, readLiveStatus } from './live-status.js';
|
|
26
28
|
import { notify as osNotify } from './notify.js';
|
|
29
|
+
import {
|
|
30
|
+
bootTimeMs, clearLock, lockIsLive, processAlive, readLock, readLockPid, writeLock,
|
|
31
|
+
} from './watch-lock.js';
|
|
27
32
|
|
|
28
33
|
const MAX_BACKOFF_MS = 15 * 60 * 1000;
|
|
29
34
|
|
|
@@ -100,25 +105,9 @@ function humanize(ms) {
|
|
|
100
105
|
|
|
101
106
|
// ------------------------------------------------------------- instance ----
|
|
102
107
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
107
|
-
} catch {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function processAlive(pid) {
|
|
113
|
-
try {
|
|
114
|
-
process.kill(pid, 0);
|
|
115
|
-
return true;
|
|
116
|
-
} catch (err) {
|
|
117
|
-
// ESRCH = no such process; EPERM = alive but owned by someone else.
|
|
118
|
-
return err.code !== 'ESRCH';
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
export { processAlive };
|
|
108
|
+
// The lock itself lives in watch-lock.js so read-only status surfaces can ask
|
|
109
|
+
// "is a watcher running?" without importing the daemon.
|
|
110
|
+
export { processAlive, readLock, lockIsLive, bootTimeMs };
|
|
122
111
|
|
|
123
112
|
/** Set while THIS process runs a watcher loop — guards same-process double start. */
|
|
124
113
|
let ownedHere = false;
|
|
@@ -133,40 +122,37 @@ export function acquireWatchLock() {
|
|
|
133
122
|
throw new Error('a watcher is already running in this process');
|
|
134
123
|
}
|
|
135
124
|
ensureDirs();
|
|
136
|
-
const existing =
|
|
137
|
-
if (existing &&
|
|
138
|
-
throw Object.assign(new Error(`a watcher is already running (pid ${existing})`), {
|
|
125
|
+
const existing = readLock();
|
|
126
|
+
if (existing && lockIsLive(existing)) {
|
|
127
|
+
throw Object.assign(new Error(`a watcher is already running (pid ${existing.pid})`), {
|
|
139
128
|
hint: '`tokenflow watch --status` shows it; `tokenflow watch --stop` stops it.',
|
|
140
129
|
});
|
|
141
130
|
}
|
|
142
|
-
|
|
131
|
+
writeLock();
|
|
143
132
|
ownedHere = true;
|
|
144
133
|
}
|
|
145
134
|
|
|
146
135
|
export function releaseWatchLock() {
|
|
147
|
-
|
|
148
|
-
const pid = readLockPid();
|
|
149
|
-
if (pid === process.pid) {
|
|
150
|
-
try { fs.unlinkSync(p); } catch { /* already gone — that is fine */ }
|
|
151
|
-
}
|
|
136
|
+
if (readLockPid() === process.pid) clearLock();
|
|
152
137
|
ownedHere = false;
|
|
153
138
|
}
|
|
154
139
|
|
|
155
140
|
/** Is a watcher running right now (and does it own the lock)? */
|
|
156
141
|
export function watchIsRunning() {
|
|
157
|
-
|
|
158
|
-
return !!(pid && processAlive(pid));
|
|
142
|
+
return lockIsLive(readLock());
|
|
159
143
|
}
|
|
160
144
|
|
|
161
145
|
export function stopWatch() {
|
|
162
|
-
const
|
|
163
|
-
if (!
|
|
164
|
-
|
|
165
|
-
|
|
146
|
+
const lock = readLock();
|
|
147
|
+
if (!lock || !lockIsLive(lock)) {
|
|
148
|
+
// Clearing here is the manual escape hatch for a lock the identity check
|
|
149
|
+
// cannot judge (an unreadable /proc, a pid the OS will not describe).
|
|
150
|
+
const had = lock ? clearLock() : false;
|
|
151
|
+
return { stopped: false, reason: had ? 'not running (cleared a stale lock)' : 'not running' };
|
|
166
152
|
}
|
|
167
153
|
try {
|
|
168
|
-
process.kill(pid, 'SIGTERM');
|
|
169
|
-
return { stopped: true, pid };
|
|
154
|
+
process.kill(lock.pid, 'SIGTERM');
|
|
155
|
+
return { stopped: true, pid: lock.pid };
|
|
170
156
|
} catch (err) {
|
|
171
157
|
return { stopped: false, reason: err.message };
|
|
172
158
|
}
|
|
@@ -204,6 +190,9 @@ export async function runCycle(opt = {}) {
|
|
|
204
190
|
? {
|
|
205
191
|
...(prev?.watcher || {}),
|
|
206
192
|
pid: process.pid,
|
|
193
|
+
// The boot this pid was issued by: a reader can tell a live watcher
|
|
194
|
+
// from a pid number the kernel has since handed to somebody else.
|
|
195
|
+
boot: Math.round(bootTimeMs()),
|
|
207
196
|
mode: 'daemon',
|
|
208
197
|
intervalSeconds: opt.daemon.intervalSeconds ?? null,
|
|
209
198
|
startedAt: prev?.watcher?.startedAt || new Date().toISOString(),
|
|
@@ -60,6 +60,26 @@
|
|
|
60
60
|
* already been emitted, and emit only the DELTA when a row grew. The base
|
|
61
61
|
* record carries the group's first_seen; a delta carries the last_seen at
|
|
62
62
|
* which the new usage was observed.
|
|
63
|
+
*
|
|
64
|
+
* ### The tail key MUST be the table's whole primary key
|
|
65
|
+
*
|
|
66
|
+
* session_model_usage is keyed on SIX columns:
|
|
67
|
+
*
|
|
68
|
+
* (session_id, model, billing_provider, billing_base_url, billing_mode, task)
|
|
69
|
+
*
|
|
70
|
+
* An earlier version of this adapter keyed its tails on four of them, leaving
|
|
71
|
+
* out billing_base_url and billing_mode. Two real rows — the same session and
|
|
72
|
+
* model, billed once with mode "" and once with mode "chat_completions" —
|
|
73
|
+
* therefore shared one tail. Each pass, each row computed its delta against
|
|
74
|
+
* the OTHER row's totals and then overwrote the tail, so the pair ping-ponged
|
|
75
|
+
* forever: every refresh cycle emitted the difference between them again, with
|
|
76
|
+
* a fresh emission index that made each one look like a new request. Five
|
|
77
|
+
* colliding sessions produced 37 BILLION phantom tokens in a single day and
|
|
78
|
+
* the totals grew with every cycle, not with usage.
|
|
79
|
+
*
|
|
80
|
+
* Two rules keep that from recurring: the key below is the full primary key,
|
|
81
|
+
* and the tail stores a HIGH-WATER MARK rather than the row's latest numbers,
|
|
82
|
+
* so a total that comes back lower can never manufacture a delta.
|
|
63
83
|
*/
|
|
64
84
|
import fs from 'node:fs';
|
|
65
85
|
import path from 'node:path';
|
|
@@ -118,6 +138,7 @@ export default createProvider({
|
|
|
118
138
|
|
|
119
139
|
const rows = db.prepare(
|
|
120
140
|
`SELECT u.session_id AS session_id, u.model AS model, u.billing_provider AS billing_provider,
|
|
141
|
+
u.billing_base_url AS billing_base_url, u.billing_mode AS billing_mode,
|
|
121
142
|
u.task AS task, u.api_call_count AS api_call_count,
|
|
122
143
|
u.input_tokens AS input_tokens, u.output_tokens AS output_tokens,
|
|
123
144
|
u.cache_read_tokens AS cache_read_tokens, u.cache_write_tokens AS cache_write_tokens,
|
|
@@ -159,7 +180,13 @@ export default createProvider({
|
|
|
159
180
|
// dashboard can show; skip it rather than emit an empty request.
|
|
160
181
|
if (FIELDS.every((k) => !base[k]) && measuredCost === null) { skipped++; continue; }
|
|
161
182
|
|
|
162
|
-
|
|
183
|
+
// The table's whole primary key — see "The tail key MUST be the
|
|
184
|
+
// table's whole primary key" above. Dropping a column here silently
|
|
185
|
+
// merges distinct rows and makes their deltas oscillate.
|
|
186
|
+
const key = [
|
|
187
|
+
r.session_id, r.model ?? '', r.billing_provider ?? '',
|
|
188
|
+
r.billing_base_url ?? '', r.billing_mode ?? '', r.task ?? '',
|
|
189
|
+
].join('|');
|
|
163
190
|
const prev = tails[key];
|
|
164
191
|
const delta = {};
|
|
165
192
|
let any = false;
|
|
@@ -177,10 +204,18 @@ export default createProvider({
|
|
|
177
204
|
// row (not against its current total), so cost never double counts.
|
|
178
205
|
const costDelta = prev && measuredCost !== null ? Math.max(0, round6(measuredCost - (prev.c || 0))) : measuredCost;
|
|
179
206
|
|
|
180
|
-
// Remember what this row accounts for
|
|
181
|
-
//
|
|
207
|
+
// Remember what this row accounts for, as a HIGH-WATER MARK. These
|
|
208
|
+
// totals only ever grow in the source table, so a lower reading is an
|
|
209
|
+
// anomaly (a source-side reset, a re-count, or two rows this adapter
|
|
210
|
+
// cannot tell apart) — and keeping the maximum means such a reading
|
|
211
|
+
// can never be turned into usage that did not happen.
|
|
182
212
|
const emissionIndex = prev ? (prev.n || 0) : 0;
|
|
183
|
-
|
|
213
|
+
const highWater = {};
|
|
214
|
+
for (const k of FIELDS) highWater[k] = Math.max(prev ? (prev.f[k] ?? 0) : 0, base[k] ?? 0);
|
|
215
|
+
const costHighWater = measuredCost === null && (prev?.c ?? null) === null
|
|
216
|
+
? null
|
|
217
|
+
: Math.max(prev?.c ?? 0, measuredCost ?? 0);
|
|
218
|
+
tails[key] = { ls: lastSeen ?? (prev?.ls || 0), f: highWater, c: costHighWater, n: emissionIndex + 1 };
|
|
184
219
|
records++;
|
|
185
220
|
|
|
186
221
|
const model = str(r.model);
|
|
@@ -239,6 +274,8 @@ export default createProvider({
|
|
|
239
274
|
hermes_task: str(r.task),
|
|
240
275
|
api_calls: intOrNull(r.api_call_count),
|
|
241
276
|
billing_provider: str(r.billing_provider),
|
|
277
|
+
billing_base_url: str(r.billing_base_url),
|
|
278
|
+
billing_mode: str(r.billing_mode),
|
|
242
279
|
cost_status: str(r.cost_status),
|
|
243
280
|
session_open: r.ended_at === null || r.ended_at === undefined ? true : undefined,
|
|
244
281
|
...(prev ? { continuation_of: key } : {}),
|
package/src/server/server.js
CHANGED
|
@@ -232,6 +232,35 @@ export async function startServer({ port = 7799, host = '127.0.0.1', open = fals
|
|
|
232
232
|
return { server, url: addr, token: authToken, close: () => new Promise((r) => server.close(r)) };
|
|
233
233
|
}
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Ask whoever is listening on host:port whether they are a TokenFlow server.
|
|
237
|
+
*
|
|
238
|
+
* Used before opening a browser window and before starting a second server:
|
|
239
|
+
* "the port is busy" and "the dashboard is already up" look identical from
|
|
240
|
+
* the outside, and only one of them is good news.
|
|
241
|
+
*
|
|
242
|
+
* @returns {Promise<object|null>} the /api/ping payload, or null when nothing
|
|
243
|
+
* there answers as TokenFlow (closed port, stranger, timeout, garbage).
|
|
244
|
+
*/
|
|
245
|
+
export function pingServer({ host = '127.0.0.1', port = 7799, timeoutMs = 1500 } = {}) {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
const req = http.get({ host, port, path: '/api/ping', timeout: timeoutMs }, (res) => {
|
|
248
|
+
if (res.statusCode !== 200) { res.resume(); return resolve(null); }
|
|
249
|
+
let body = '';
|
|
250
|
+
res.setEncoding('utf8');
|
|
251
|
+
res.on('data', (c) => { body += c.length > 4096 ? '' : c; });
|
|
252
|
+
res.on('end', () => {
|
|
253
|
+
try {
|
|
254
|
+
const o = JSON.parse(body);
|
|
255
|
+
resolve(o && o.app === 'tokenflow' ? o : null);
|
|
256
|
+
} catch { resolve(null); }
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
260
|
+
req.on('error', () => resolve(null));
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
235
264
|
function send(res, code, type, body) {
|
|
236
265
|
res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' });
|
|
237
266
|
res.end(body);
|