@solongate/proxy 0.83.66 → 0.83.68

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.
@@ -36,12 +36,20 @@
36
36
  * turn and must never be the reason one fails: it records something ABOUT the
37
37
  * work, and the work matters more.
38
38
  */
39
- import { readFileSync, existsSync } from 'node:fs';
39
+ import { readFileSync, existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
40
40
  import { resolve } from 'node:path';
41
41
  import { homedir } from 'node:os';
42
+ import { createRequire } from 'node:module';
43
+
44
+ const require = createRequire(import.meta.url);
42
45
 
43
46
  // Bump on every change to this file, alongside the other hooks.
44
- const HOOK_VERSION = 3;
47
+ //
48
+ // 4 adds the token report: what a turn COST, sent beside the record of what was
49
+ // said. It rides on this hook because the events are the same events — a turn
50
+ // ends once, and a second registration for the same moment is a second thing to
51
+ // install and a second thing that can be missing.
52
+ const HOOK_VERSION = 4;
45
53
 
46
54
  const AGENT_ID = (process.env.SOLONGATE_AGENT_ID || process.argv[2] || 'default').replace(/[^a-zA-Z0-9_-]/g, '_');
47
55
 
@@ -180,6 +188,359 @@ async function sendAntigravity(common, stop) {
180
188
  if (String(reply).trim()) await send(sessionId, 'reply', reply, name);
181
189
  }
182
190
 
191
+ // ── what a turn COST ───────────────────────────────────────────────────────
192
+ //
193
+ // Inlined rather than imported from a sibling, and that is not tidiness. Hooks
194
+ // are installed as individual files by name and refreshed over the wire the
195
+ // same way; a conversation.mjs that imports ./tokens.mjs would MODULE_NOT_FOUND
196
+ // on any machine whose installer predates the second file — and an import that
197
+ // throws takes the whole hook with it, losing the conversation record as well
198
+ // as the figure. One file cannot be half-installed.
199
+ // How much of a transcript to read. A turn is a few kilobytes; two megabytes is
200
+ // a long turn with large tool results in it, and reading a 50MB transcript on
201
+ // every Stop is the cost this bound exists to refuse.
202
+ const TAIL_BYTES = 2 * 1024 * 1024;
203
+
204
+ // One request per Stop carries at most this many turns.
205
+ //
206
+ // Forty rather than everything in the tail. The server keys on the turn id and
207
+ // ignores a repeat, so re-sending is free of consequence but not of bytes —
208
+ // forty covers a missed Stop or two while keeping the request small, and the
209
+ // ones before that were sent when they happened.
210
+ const MAX_TURNS = 40;
211
+
212
+ /** Read the last bytes of a file, dropping the first partial line. */
213
+ function tailLines(path, bytes = TAIL_BYTES) {
214
+ let fd;
215
+ try {
216
+ const size = statSync(path).size;
217
+ const from = Math.max(0, size - bytes);
218
+ const len = size - from;
219
+ if (len <= 0) return [];
220
+ const buf = Buffer.allocUnsafe(len);
221
+ fd = openSync(path, 'r');
222
+ readSync(fd, buf, 0, len, from);
223
+ const text = buf.toString('utf-8');
224
+ const lines = text.split('\n');
225
+ // A read that started mid-file starts mid-line. That first fragment is not
226
+ // JSON and would be the one line that throws on every single turn.
227
+ if (from > 0) lines.shift();
228
+ return lines;
229
+ } catch {
230
+ return [];
231
+ } finally {
232
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already gone */ } }
233
+ }
234
+ }
235
+
236
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
237
+ const stamp = (v) => {
238
+ const t = Date.parse(v || '');
239
+ return Number.isFinite(t) ? t : Date.now();
240
+ };
241
+
242
+ // ── Claude Code ────────────────────────────────────────────────────────────
243
+
244
+ /**
245
+ * One row per API REQUEST, deduped.
246
+ *
247
+ * The dedupe is the whole correctness of this reader. Usage is reported per API
248
+ * request but the transcript writes one record per content block, so the same
249
+ * usage object appears up to four times with the same requestId. Summing
250
+ * records rather than requests overcounts by roughly half.
251
+ */
252
+ function readClaudeCode(transcriptPath) {
253
+ const seen = new Set();
254
+ const out = [];
255
+ for (const line of tailLines(transcriptPath)) {
256
+ if (!line || line[0] !== '{') continue;
257
+ let r;
258
+ try { r = JSON.parse(line); } catch { continue; }
259
+ if (r.type !== 'assistant') continue;
260
+ const u = r.message && r.message.usage;
261
+ if (!u) continue;
262
+
263
+ // requestId is the API request's identity. A few synthetic records carry
264
+ // none — API-error placeholders with all-zero usage — so the message id is
265
+ // the fallback rather than letting them collide on null.
266
+ const key = r.requestId || (r.message && r.message.id) || '';
267
+ if (!key || seen.has(key)) continue;
268
+ seen.add(key);
269
+
270
+ const input = num(u.input_tokens);
271
+ const cacheWrite = num(u.cache_creation_input_tokens);
272
+ const cacheRead = num(u.cache_read_input_tokens);
273
+ const output = num(u.output_tokens);
274
+ const total = input + cacheWrite + cacheRead + output;
275
+ if (total <= 0) continue;
276
+
277
+ out.push({
278
+ turn_key: key,
279
+ input, output, cache_read: cacheRead, cache_write: cacheWrite,
280
+ reasoning: num(u.output_tokens_details && u.output_tokens_details.thinking_tokens),
281
+ // The three prompt buckets are DISJOINT — proven on real data, where
282
+ // cache_read[n+1] == cache_read[n] + cache_creation[n] holds to the
283
+ // token — so the sum is the whole prompt plus the completion.
284
+ total,
285
+ at: stamp(r.timestamp),
286
+ });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ // ── Codex ──────────────────────────────────────────────────────────────────
292
+
293
+ /**
294
+ * One row per model request, from the token_count events.
295
+ *
296
+ * `last_token_usage` rather than `total_token_usage`: the second is cumulative
297
+ * for the session, and sending cumulative values to a store that adds rows
298
+ * would count the whole session again on every turn.
299
+ */
300
+ function readCodex(transcriptPath) {
301
+ const out = [];
302
+ let ordinal = 0;
303
+ for (const line of tailLines(transcriptPath)) {
304
+ if (!line || line[0] !== '{') continue;
305
+ let r;
306
+ try { r = JSON.parse(line); } catch { continue; }
307
+ if (r.type !== 'event_msg') continue;
308
+ const p = r.payload;
309
+ if (!p || p.type !== 'token_count') continue;
310
+
311
+ ordinal++;
312
+ const last = p.info && p.info.last_token_usage;
313
+ if (!last) continue;
314
+
315
+ const total = num(last.total_tokens) || (num(last.input_tokens) + num(last.output_tokens));
316
+ if (total <= 0) continue;
317
+
318
+ out.push({
319
+ // The rollout file is append-only and the events are in order, so the
320
+ // count of token_count events before this one is stable across re-reads
321
+ // of the same file. Paired with the timestamp so a truncated tail cannot
322
+ // renumber a turn that was already sent.
323
+ turn_key: 'tc:' + ordinal + ':' + (r.timestamp || ''),
324
+ // input_tokens INCLUDES cached here, and output INCLUDES reasoning —
325
+ // the opposite of Claude Code's disjoint buckets. The total is the
326
+ // provider's own, so the difference never has to be reconciled.
327
+ input: num(last.input_tokens),
328
+ output: num(last.output_tokens),
329
+ cache_read: num(last.cached_input_tokens),
330
+ cache_write: num(last.cache_write_input_tokens),
331
+ reasoning: num(last.reasoning_output_tokens),
332
+ total,
333
+ at: stamp(r.timestamp),
334
+ });
335
+ }
336
+ return out;
337
+ }
338
+
339
+ // ── Antigravity ────────────────────────────────────────────────────────────
340
+
341
+ /** Read a protobuf varint. Returns [value, nextIndex]. */
342
+ function varint(buf, i) {
343
+ let result = 0n;
344
+ let shift = 0n;
345
+ while (i < buf.length) {
346
+ const b = buf[i++];
347
+ result |= BigInt(b & 0x7f) << shift;
348
+ if (!(b & 0x80)) return [result, i];
349
+ shift += 7n;
350
+ if (shift > 70n) break;
351
+ }
352
+ return [result, i];
353
+ }
354
+
355
+ /**
356
+ * Pull the varints at one dotted protobuf path out of a blob.
357
+ *
358
+ * A tiny hand-rolled walker rather than a schema, because there is no schema on
359
+ * disk: Antigravity stores an opaque message and the field numbers below were
360
+ * established by decoding real generations and testing an identity that held
361
+ * every time.
362
+ */
363
+ function pbFields(buf, want) {
364
+ const found = {};
365
+ const walk = (b, path, depth) => {
366
+ let i = 0;
367
+ while (i < b.length) {
368
+ let key;
369
+ [key, i] = varint(b, i);
370
+ const field = Number(key >> 3n);
371
+ const wire = Number(key & 7n);
372
+ const here = path ? path + '.' + field : String(field);
373
+ if (wire === 0) {
374
+ let v;
375
+ [v, i] = varint(b, i);
376
+ if (want.has(here) && found[here] === undefined) found[here] = Number(v);
377
+ } else if (wire === 2) {
378
+ let len;
379
+ [len, i] = varint(b, i);
380
+ const end = i + Number(len);
381
+ if (end > b.length) return;
382
+ if (depth < 6) walk(b.subarray(i, end), here, depth + 1);
383
+ i = end;
384
+ } else if (wire === 5) { i += 4; }
385
+ else if (wire === 1) { i += 8; }
386
+ else { return; }
387
+ }
388
+ };
389
+ try { walk(buf, '', 0); } catch { /* a blob we cannot read is a blob we skip */ }
390
+ return found;
391
+ }
392
+
393
+ // The field numbers, and what each one is. See the file header for how they
394
+ // were established — this is the one client whose mapping is not published, so
395
+ // it is the one where the evidence matters most.
396
+ const AGY = {
397
+ IN_UNCACHED: '1.4.2',
398
+ IN_CACHED: '1.4.5',
399
+ OUT: '1.4.3',
400
+ THINKING: '1.4.9',
401
+ // The generation's own clock: a google.protobuf.Timestamp, whose field 1 is
402
+ // seconds. Without it every generation in the tail would be stamped with the
403
+ // moment it was READ, which puts a week of work on today's bar.
404
+ AT_SECONDS: '1.9.4.1',
405
+ };
406
+ const AGY_WANT = new Set(Object.values(AGY));
407
+
408
+ function readAntigravity(conversationId) {
409
+ if (!conversationId) return [];
410
+ const db = resolve(homedir(), '.gemini', 'antigravity-cli', 'conversations', conversationId + '.db');
411
+ if (!existsSync(db)) return [];
412
+
413
+ let DatabaseSync;
414
+ try {
415
+ // Node 22+. On an older runtime there is no way to read this without a
416
+ // native dependency, and adding one to a security tool to report a number
417
+ // is the wrong trade — the client simply reports nothing.
418
+ ({ DatabaseSync } = require('node:sqlite'));
419
+ } catch {
420
+ return [];
421
+ }
422
+
423
+ let handle;
424
+ try {
425
+ // Read-only: this is the client's own live database and nothing here has
426
+ // any business writing to it.
427
+ handle = new DatabaseSync(db, { readOnly: true });
428
+ const rows = handle.prepare('SELECT idx, data FROM gen_metadata ORDER BY idx').all();
429
+ const out = [];
430
+ for (const row of rows) {
431
+ const blob = row.data;
432
+ if (!blob || !blob.length) continue;
433
+ const f = pbFields(Buffer.from(blob), AGY_WANT);
434
+
435
+ const input = (f[AGY.IN_UNCACHED] || 0) + (f[AGY.IN_CACHED] || 0);
436
+ const output = f[AGY.OUT] || 0;
437
+ if (input + output <= 0) continue;
438
+
439
+ out.push({
440
+ // The generation index, which is this client's own identifier for the
441
+ // model call and is stable across re-reads of the same conversation.
442
+ turn_key: 'gen:' + row.idx,
443
+ input,
444
+ output,
445
+ // The cached half of the prompt, which Antigravity reports separately
446
+ // and is a SUBSET of the input above.
447
+ cache_read: f[AGY.IN_CACHED] || 0,
448
+ cache_write: 0,
449
+ reasoning: f[AGY.THINKING] || 0,
450
+ total: input + output,
451
+ at: (f[AGY.AT_SECONDS] || 0) * 1000,
452
+ });
453
+ }
454
+ return out;
455
+ } catch {
456
+ return [];
457
+ } finally {
458
+ if (handle) { try { handle.close(); } catch { /* already closed */ } }
459
+ }
460
+ }
461
+
462
+
463
+ // ── sending ────────────────────────────────────────────────────────────────
464
+
465
+ /**
466
+ * collectTokens works out which client this payload is from and reads its
467
+ * numbers. Returns [] when the client cannot report, which is a normal answer
468
+ * and never a zero.
469
+ */
470
+ function collectTokens(data, source) {
471
+ const transcript = data.transcript_path || data.transcriptPath ||
472
+ (data.common && (data.common.transcriptPath || data.common.transcript_path)) || '';
473
+
474
+ if (source === 'antigravity') {
475
+ const id = (data.common && (data.common.conversationId || data.common.conversation_id)) || '';
476
+ return readAntigravity(id);
477
+ }
478
+ if (!transcript || !existsSync(transcript)) return [];
479
+ if (source === 'codex') return readCodex(transcript);
480
+ return readClaudeCode(transcript);
481
+ }
482
+
483
+ /**
484
+ * sendTokens posts a batch. One request per Stop whatever the count.
485
+ *
486
+ * Silent on every failure, including a missing credential: a machine that is
487
+ * not logged in has nowhere to report and nothing to say about it.
488
+ */
489
+ async function sendTokens({ apiUrl, apiKey, sessionId, agentId, agentName, source, turns }) {
490
+ if (!apiKey || !turns || !turns.length) return;
491
+ const body = turns.slice(-MAX_TURNS).map((t) => ({
492
+ ...t,
493
+ session_id: sessionId || '',
494
+ agent_id: agentId || '',
495
+ agent_name: agentName || '',
496
+ source: source || '',
497
+ }));
498
+ try {
499
+ await fetch(`${apiUrl.replace(/\/$/, '')}/api/v1/token-usage`, {
500
+ method: 'POST',
501
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
502
+ body: JSON.stringify({ turns: body }),
503
+ });
504
+ } catch { /* silent: a figure that could not be sent must not fail a turn */ }
505
+ }
506
+
507
+ // reportTokens sends what the turn cost, beside the record of what was said.
508
+ //
509
+ // It rides on THIS hook rather than on one of its own because the events are
510
+ // the same events: a turn ends once, and registering a second hook for the same
511
+ // moment is a second thing to install, a second thing to keep current, and a
512
+ // second thing that can be missing on a machine where the first is fine.
513
+ //
514
+ // Everything it needs is already on the payload. It is awaited so a short-lived
515
+ // hook process does not exit before the request goes out, and it can only
516
+ // fail silently — see tokens.mjs.
517
+ async function reportTokens(data, source, sessionId, agentName) {
518
+ try {
519
+ const turns = collectTokens(data, source);
520
+ if (!turns.length) return;
521
+ const cfg = loadGlobalCloudConfig();
522
+ await sendTokens({
523
+ apiUrl: cfg.apiUrl || 'https://api.solongate.com',
524
+ apiKey: cfg.apiKey || '',
525
+ sessionId,
526
+ agentId: AGENT_ID,
527
+ agentName: agentName || '',
528
+ source,
529
+ turns,
530
+ });
531
+ } catch { /* silent: spend is a note about the work, not the work */ }
532
+ }
533
+
534
+ // tokenSourceOf names the client from the payload's SHAPE rather than from the
535
+ // argv the hook was installed with, for the reason the Antigravity branch below
536
+ // gives: a payload is a fact and an argument is a claim.
537
+ function tokenSourceOf(data) {
538
+ if (data.common || (data.hookArgs && data.hookArgs.common)) return 'antigravity';
539
+ const path = String(data.transcript_path || data.transcriptPath || '');
540
+ if (path.includes('/.codex/') || path.includes('\\.codex\\')) return 'codex';
541
+ return 'claude-code';
542
+ }
543
+
183
544
  (async () => {
184
545
  // Whatever happens below, this hook allows the turn. It has no opinion about
185
546
  // whether the work should proceed; it is a record of it.
@@ -206,6 +567,30 @@ async function sendAntigravity(common, stop) {
206
567
  // chat.message hook for the prompt, streamed text parts for the reply), so
207
568
  // the plugin does the deciding and this file does the sending.
208
569
  if (data.opencode === true) {
570
+ // Its spend arrives already counted, in process, rather than being read
571
+ // back off a transcript the way the other three are.
572
+ if (data.tokens === true) {
573
+ const cfg = loadGlobalCloudConfig();
574
+ await sendTokens({
575
+ apiUrl: cfg.apiUrl || 'https://api.solongate.com',
576
+ apiKey: cfg.apiKey || '',
577
+ sessionId: data.session_id || '',
578
+ agentId: AGENT_ID,
579
+ agentName: 'OpenCode',
580
+ source: 'opencode',
581
+ turns: [{
582
+ turn_key: data.turn_key || '',
583
+ input: data.input || 0,
584
+ output: data.output || 0,
585
+ cache_read: data.cache_read || 0,
586
+ cache_write: data.cache_write || 0,
587
+ reasoning: data.reasoning || 0,
588
+ total: data.total || 0,
589
+ at: data.at || 0,
590
+ }],
591
+ });
592
+ return;
593
+ }
209
594
  await send(data.session_id || '', data.role === 'reply' ? 'reply' : 'prompt',
210
595
  data.body || '', 'OpenCode');
211
596
  return;
@@ -216,6 +601,14 @@ async function sendAntigravity(common, stop) {
216
601
  (data.hookArgs && data.hookArgs.stopHookArgs) || null;
217
602
  if (agCommon && (agStop || agCommon.lastUserInput)) {
218
603
  await sendAntigravity(agCommon, agStop);
604
+ // Only at the END of an exchange: this hook fires on several events and the
605
+ // generations are already on disk, so reading them on every one would be
606
+ // the same rows read many times for nothing.
607
+ if (agStop) {
608
+ await reportTokens(data, 'antigravity',
609
+ agCommon.conversationId || agCommon.conversation_id || '',
610
+ agCommon.agentName || agCommon.agent_name || 'Antigravity');
611
+ }
219
612
  return;
220
613
  }
221
614
 
@@ -235,10 +628,16 @@ async function sendAntigravity(common, stop) {
235
628
  } else {
236
629
  return;
237
630
  }
238
- if (!String(body).trim()) return;
239
-
240
631
  const sessionId = data.session_id || data.sessionId || data.conversation_id || '';
241
632
  if (!sessionId) return;
242
633
 
634
+ // The cost is read at Stop, whether or not there were words to record. A turn
635
+ // that ended with a tool call and no prose still cost something, and gating
636
+ // the figure on the transcript would lose exactly the turns that ran longest.
637
+ if (role === 'reply') {
638
+ await reportTokens(data, tokenSourceOf(data), sessionId, data.agent_name || '');
639
+ }
640
+
641
+ if (!String(body).trim()) return;
243
642
  await send(sessionId, role, body, data.agent_name || '');
244
643
  })();
@@ -202,6 +202,11 @@ export const SolonGate = async ({ directory, worktree } = {}) => {
202
202
 
203
203
  // A text part of a message. `text` is the WHOLE text so far rather than a
204
204
  // delta, so the last one wins and nothing is concatenated.
205
+ // numberOr keeps a missing or nonsense count out of a spend figure. A
206
+ // field this SDK version does not declare — info.tokens.total is one —
207
+ // arrives undefined rather than zero, and Number(undefined) is NaN.
208
+ const numberOr = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.round(v) : 0);
209
+
205
210
  if (event.type === 'message.part.updated') {
206
211
  const part = event.properties?.part;
207
212
  if (part?.type === 'text' && part.messageID && typeof part.text === 'string') {
@@ -216,6 +221,42 @@ export const SolonGate = async ({ directory, worktree } = {}) => {
216
221
  if (event.type === 'message.updated') {
217
222
  const info = event.properties?.info;
218
223
  if (!info || info.role !== 'assistant' || !info.time?.completed) return;
224
+
225
+ // WHAT THE MESSAGE COST, which OpenCode is alone among the four clients
226
+ // in handing over directly: the other three have to be read back off a
227
+ // transcript or a database, and this arrives in process, already
228
+ // counted by the provider. It was in `info` all along and thrown away.
229
+ //
230
+ // Sent whether or not there was text to record. A message that produced
231
+ // only tool calls still cost something, and gating the figure on the
232
+ // words would lose exactly the turns that ran longest.
233
+ const t = info.tokens;
234
+ if (t) {
235
+ const input = numberOr(t.input);
236
+ const output = numberOr(t.output);
237
+ const reasoning = numberOr(t.reasoning);
238
+ const cacheRead = numberOr(t.cache?.read);
239
+ const cacheWrite = numberOr(t.cache?.write);
240
+ // OpenCode's own total when it sends one — it counts reasoning
241
+ // ALONGSIDE output rather than inside it, which is its convention and
242
+ // not ours to reconcile.
243
+ const total = numberOr(t.total) || input + output + reasoning + cacheRead + cacheWrite;
244
+ if (total > 0) {
245
+ void runHook(CONVERSATION, {
246
+ opencode: true,
247
+ tokens: true,
248
+ session_id: info.sessionID || '',
249
+ turn_key: info.id || '',
250
+ agent_name: 'OpenCode',
251
+ input, output, reasoning,
252
+ cache_read: cacheRead,
253
+ cache_write: cacheWrite,
254
+ total,
255
+ at: info.time?.completed ? Number(info.time.completed) : 0,
256
+ }, GUARD_TIMEOUT_MS);
257
+ }
258
+ }
259
+
219
260
  const held = pending.get(info.id);
220
261
  pending.delete(info.id);
221
262
  if (!held || !held.text.trim()) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solongate/proxy",
3
- "version": "0.83.66",
3
+ "version": "0.83.68",
4
4
  "description": "AI tool security proxy: protect any AI tool server with customizable policies, path/command constraints, rate limiting, and audit logging. No code changes required.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,12 +61,12 @@
61
61
  "node": ">=20.0.0"
62
62
  },
63
63
  "optionalDependencies": {
64
- "@solongate/guard-linux-x64": "0.83.66",
65
- "@solongate/guard-linux-arm64": "0.83.66",
66
- "@solongate/guard-darwin-x64": "0.83.66",
67
- "@solongate/guard-darwin-arm64": "0.83.66",
68
- "@solongate/guard-win32-x64": "0.83.66",
69
- "@solongate/guard-win32-arm64": "0.83.66"
64
+ "@solongate/guard-linux-x64": "0.83.68",
65
+ "@solongate/guard-linux-arm64": "0.83.68",
66
+ "@solongate/guard-darwin-x64": "0.83.68",
67
+ "@solongate/guard-darwin-arm64": "0.83.68",
68
+ "@solongate/guard-win32-x64": "0.83.68",
69
+ "@solongate/guard-win32-arm64": "0.83.68"
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/sdk": "^1.26.0",