@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.
- package/hooks/conversation.mjs +403 -4
- package/hooks/opencode-plugin.mjs +41 -0
- package/package.json +7 -7
package/hooks/conversation.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
65
|
-
"@solongate/guard-linux-arm64": "0.83.
|
|
66
|
-
"@solongate/guard-darwin-x64": "0.83.
|
|
67
|
-
"@solongate/guard-darwin-arm64": "0.83.
|
|
68
|
-
"@solongate/guard-win32-x64": "0.83.
|
|
69
|
-
"@solongate/guard-win32-arm64": "0.83.
|
|
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",
|