troxy-cli 1.30.3 → 1.30.5

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": "troxy-cli",
3
- "version": "1.30.3",
3
+ "version": "1.30.5",
4
4
  "description": "A secure control layer for AI agents: policies across payments, messages, logins, destructive actions, model usage, and secrets, all enforceable from the CLI",
5
5
  "homepage": "https://troxy.io",
6
6
  "bugs": {
@@ -161,7 +161,7 @@ function bar(fraction) {
161
161
  }
162
162
 
163
163
  // One line, most important first, e.g.
164
- // This session: savings under measurement · 5h: 19% used · 7d: 12% used
164
+ // 5h: 19% used · 7d: 12% used
165
165
  function savedLine(money, rateLimits = []) {
166
166
  const paid = money && money.cost_paid_usd;
167
167
  if (!(paid > 0)) return undefined;
@@ -170,8 +170,7 @@ function savedLine(money, rateLimits = []) {
170
170
  // measured replacement exists.
171
171
  const moved = rateLimits.filter(w => WINDOW_LABEL[w.kind] && Math.round(w.percentUsed) >= 1);
172
172
  if (!(money.dollars_saved_usd > 0) && !moved.length) return undefined; // nothing routed or cleared yet
173
- return ['This session: savings under measurement',
174
- ...moved.map(w => `${WINDOW_LABEL[w.kind]}: ${Math.round(w.percentUsed)}% used`)].join(' · ');
173
+ return moved.map(w => `${WINDOW_LABEL[w.kind]}: ${Math.round(w.percentUsed)}% used`).join(' · ') || undefined;
175
174
  }
176
175
 
177
176
  // t.kept: what the content Troxy cleared or compressed would have cost (server's
@@ -181,19 +180,15 @@ function sessionLine(t) {
181
180
  Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
182
181
  }
183
182
 
184
- // This session's Troxy work, from the server's count of its calls: how many ran on a
185
- // lighter model than asked, plus tokens kept out of requests (compression + trimming,
186
- // API-key setups through the proxy only). A holdout session says so: it is never routed.
187
- // undefined until there is something to say.
183
+ // What Troxy did in this session, from the server's count of its calls: the share that
184
+ // ran on a lighter model instead of the model asked for. A holdout session says so: it is never
185
+ // routed. undefined until there is something to say. (Tokens cleared by the old proxy are
186
+ // not shown: subscription sessions no longer go through it.)
188
187
  function savingsText(s, arm) {
189
188
  const calls = (s && s.calls) || 0, routed = (s && s.routed_calls) || 0;
190
- const kept = ((s && s.compressed_tokens) || 0) + ((s && s.trimmed_tokens) || 0);
191
- const k = n => n >= 1e6 ? `${Math.round(n / 1e5) / 10}M`.replace('.0M', 'M') : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
192
- if (arm === 'holdout') return calls ? 'comparison session: not routed, so Troxy can measure the difference' : undefined;
193
- const parts = [];
194
- if (calls) parts.push(`${routed} of ${calls} calls on a lighter model`);
195
- if (kept > 0) parts.push(`${k(kept)} tokens not re-sent`);
196
- return parts.length ? parts.join(' · ') : undefined;
189
+ if (!calls) return undefined;
190
+ if (arm === 'holdout') return 'comparison session: not routed, so Troxy can measure the difference';
191
+ return routed ? `${Math.max(1, Math.round(routed / calls * 100))}% of this session ran on a lighter model` : 'no calls moved to a lighter model yet';
197
192
  }
198
193
 
199
194
  // This session's savings from the server, or null when there is no key, no session id or
@@ -235,7 +230,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
235
230
  let pending = [];
236
231
  let compacting = false;
237
232
 
238
- function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces }) {
233
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces, compressed }) {
239
234
  const rl = {};
240
235
  for (const w of rateLimits || []) {
241
236
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -248,10 +243,26 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
248
243
  compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
249
244
  cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
250
245
  rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
246
+ compressed_tokens: compressed > 0 ? compressed : null,
251
247
  surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
252
248
  };
253
249
  }
254
250
 
251
+ // Tokens the compression hook kept out of this session since the last report (it appends one
252
+ // line per compressed tool output, see sandbox/posttooluse-hook.js). Each count is reported once.
253
+ const savedSeen = new Map();
254
+ async function compressedSince($, sid) {
255
+ try {
256
+ if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sid))) return 0;
257
+ const home = await $.env.get('HOME');
258
+ const text = await $.fs.read(`${home}/.troxy/sandbox/saved/${sid}.log`);
259
+ const total = String(text).split('\n').reduce((n, l) => n + (Number(l) || 0), 0);
260
+ const delta = Math.max(0, total - (savedSeen.get(sid) || 0));
261
+ savedSeen.set(sid, total);
262
+ return delta;
263
+ } catch { return 0; }
264
+ }
265
+
255
266
  async function noteStep($, result, meta) {
256
267
  const usage = result && result.usage;
257
268
  if (!usage || !usage.model) return;
@@ -259,7 +270,8 @@ async function noteStep($, result, meta) {
259
270
  try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
260
271
  let surfaces = [];
261
272
  try { surfaces = await $.session.surfaces(); } catch {}
262
- pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, surfaces, ts: await $.clock.now() }));
273
+ const sid = await $.session.id();
274
+ pending.push(reportRow({ ...meta, sid, usage, compact: compacting, rateLimits, surfaces, compressed: await compressedSince($, sid), ts: await $.clock.now() }));
263
275
  if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
264
276
  }
265
277
 
@@ -278,10 +290,9 @@ async function flushReport($) {
278
290
  }
279
291
 
280
292
  // /troxy status: everything about this session's Troxy in one place.
281
- function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
293
+ function statusText({ version, paused, savings, rateLimits = [], compactOff = false }) {
282
294
  const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
283
- lines.push(sessionLine(totals) ? `This session: ${sessionLine(totals).replace(/^.*?This session: /, '')}`
284
- : 'This session: nothing moved to a cheaper model yet');
295
+ lines.push(`This session: ${savings || 'nothing moved to a lighter model yet'}`);
285
296
  const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
286
297
  if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
287
298
  lines.push('The model is picked at the start, after an hour idle or after a compaction, then kept (switching would rewrite the cache). /model always wins.');
@@ -606,8 +617,7 @@ export function register(on) {
606
617
  await $.store.set('paused', r.paused);
607
618
  if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
608
619
  const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
609
- const { value: totals } = await $.state.get(SAVED);
610
- return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
620
+ return { text: statusText({ version: manifest.version, paused: r.paused, savings: (await $.state.get(SAVINGS)).value, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
611
621
  });
612
622
  // Routing fails open: a scorer or hook error must never break the session,
613
623
  // it just means the requested model is used as-is.
@@ -624,7 +634,7 @@ export function register(on) {
624
634
  const { value: kept } = await $.state.get(SAVINGS);
625
635
  if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
626
636
  const { Text } = $.ui.resolve(e);
627
- return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note && note.text].filter(Boolean).join(' · ')}`);
637
+ return h(Text, { color: TROXY_TEAL }, `Troxy · ${[kept, line, note && note.text].filter(Boolean).join(' · ')}`);
628
638
  });
629
639
  // After the answer, so counting never delays a prompt.
630
640
  on('turn.complete', async ($, e, next) => {
@@ -655,4 +665,4 @@ export function register(on) {
655
665
  }
656
666
 
657
667
  // Exported for tests only - not part of the mod's runtime contract.
658
- export const _internal = { stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
668
+ export const _internal = { compressedSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
@@ -38,6 +38,18 @@ function sessionArm(dir, sessionId, holdoutFraction, randomFn) {
38
38
  return arm;
39
39
  }
40
40
 
41
+ // One line per compression: the tokens it kept out of the conversation (about 4 characters
42
+ // each). The troxy-guard mod reads this per session and reports it, since in the mod-only setup
43
+ // no proxy sees the traffic. Append-only so parallel hook processes never overwrite each other.
44
+ export function recordSaved(dir, sessionId, tokens) {
45
+ if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId) || !(tokens > 0)) return;
46
+ try {
47
+ const file = path.join(dir, 'saved', `${sessionId}.log`);
48
+ fs.mkdirSync(path.dirname(file), { recursive: true });
49
+ fs.appendFileSync(file, `${Math.round(tokens)}\n`);
50
+ } catch { /* fails open: a missed count never blocks the tool output */ }
51
+ }
52
+
41
53
  function writeState(dir, state) {
42
54
  try {
43
55
  fs.mkdirSync(dir, { recursive: true });
@@ -117,6 +129,7 @@ export async function buildHookResponse(toolUseBlock, {
117
129
  const { handle } = await put(output, { summary, tags: [] }, storeDir);
118
130
  writeState(storeDir, 'treated');
119
131
  const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
132
+ recordSaved(storeDir, toolUseBlock?.session_id, (output.length - updatedText.length) / 4);
120
133
  return {
121
134
  hookSpecificOutput: {
122
135
  hookEventName: 'PostToolUse',
@@ -67,10 +67,10 @@ test('savedLine: no savings claim until measured, then the meter', () => {
67
67
  const { savedLine } = _internal;
68
68
  const meter = [{ kind: 'five_hour', percentUsed: 19 }, { kind: 'seven_day', percentUsed: 12.4 }];
69
69
  assert.equal(savedLine({ cost_paid_usd: 19, dollars_saved_usd: 13 }, meter),
70
- 'This session: savings under measurement · 5h: 19% used · 7d: 12% used');
70
+ '5h: 19% used · 7d: 12% used');
71
71
  assert.equal(savedLine({ cost_paid_usd: 92, dollars_saved_usd: 8 }, [{ kind: 'five_hour', percentUsed: 12 }, { kind: 'seven_day', percentUsed: 0 }]),
72
- 'This session: savings under measurement · 5h: 12% used');
73
- assert.equal(savedLine({ cost_paid_usd: 1.64, dollars_saved_usd: 4.53 }, []), 'This session: savings under measurement');
72
+ '5h: 12% used');
73
+ assert.equal(savedLine({ cost_paid_usd: 1.64, dollars_saved_usd: 4.53 }, []), undefined);
74
74
  assert.equal(savedLine(null, meter), undefined);
75
75
  assert.doesNotMatch(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 50 }, meter), /longer|instead of/);
76
76
  });
@@ -83,11 +83,11 @@ test('addTurn: a session total from its own turns, a routed one counted at the a
83
83
  // Not routed; the 5h window reset mid-turn, so its move counts as 0, not negative.
84
84
  t = addTurn(t, { cost: 1.5, meter: { five_hour: 13, seven_day: 5 } }, { cost: 2, meter: { five_hour: 1, seven_day: 6 } }, 1);
85
85
  assert.deepEqual(t, { paid: 1, full: 1.5, meter: { five_hour: 3, seven_day: 2 } });
86
- assert.equal(sessionLine(t), 'This session: savings under measurement · 5h: 3% used · 7d: 2% used');
86
+ assert.equal(sessionLine(t), '5h: 3% used · 7d: 2% used');
87
87
  // Cleared/compressed content counts too, and survives the next turn: $1 paid, $0.5 swap + $1.5 kept = 3x.
88
88
  t = addTurn({ ...t, kept: 1.5 }, { cost: 2, meter: {} }, { cost: 2, meter: {} }, 1);
89
89
  assert.equal(t.kept, 1.5);
90
- assert.match(sessionLine(t), /savings under measurement · 5h: 3% used/);
90
+ assert.match(sessionLine(t), /5h: 3% used/);
91
91
  // Server total of a session already 28 dollars deep: only growth after the first reading counts.
92
92
  const { withKept } = _internal;
93
93
  let k = { paid: 1, full: 1, meter: {} };
@@ -102,16 +102,15 @@ test('addTurn: a session total from its own turns, a routed one counted at the a
102
102
 
103
103
  test('statusText: version, routing, this session, plan usage, where the account view is', () => {
104
104
  const { statusText } = _internal;
105
- const totals = { paid: 0.03, full: 0.12, meter: {} };
106
- assert.equal(statusText({ version: '1.29.42', paused: false, totals, rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
105
+ assert.equal(statusText({ version: '1.29.42', paused: false, savings: '55% of this session ran on a lighter model', rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
107
106
  'Troxy 1.29.42 · routing is on\n'
108
- + 'This session: savings under measurement\n'
107
+ + 'This session: 55% of this session ran on a lighter model\n'
109
108
  + 'Plan usage now: 5h 23% · 7d 77%\n'
110
109
  + 'The model is picked at the start, after an hour idle or after a compaction, then kept (switching would rewrite the cache). /model always wins.\n'
111
110
  + 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
112
111
  + 'All sessions: dash.troxy.io\n'
113
112
  + 'Type /troxy off to pause routing.');
114
- assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a cheaper model yet\n/);
113
+ assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a lighter model yet\n/);
115
114
  });
116
115
 
117
116
  test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {
@@ -128,11 +127,11 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
128
127
  assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
129
128
  });
130
129
 
131
- test('savingsText: calls on a lighter model, kept tokens only when there are some, holdout says so', () => {
130
+ test('savingsText: share of calls on a lighter model, holdout says so, nothing before the first call', () => {
132
131
  const { savingsText } = _internal;
133
- assert.equal(savingsText({ calls: 5, routed_calls: 5, compressed_tokens: 0, trimmed_tokens: 0 }), '5 of 5 calls on a lighter model');
134
- assert.equal(savingsText({ calls: 4, routed_calls: 1, compressed_tokens: 5200, trimmed_tokens: 40100 }), '1 of 4 calls on a lighter model · 45k tokens not re-sent');
135
- assert.equal(savingsText({ calls: 9, routed_calls: 0, compressed_tokens: 123277, trimmed_tokens: 154512861 }), '0 of 9 calls on a lighter model · 154.6M tokens not re-sent');
132
+ assert.equal(savingsText({ calls: 5, routed_calls: 5 }), '100% of this session ran on a lighter model');
133
+ assert.equal(savingsText({ calls: 1121, routed_calls: 622, trimmed_tokens: 35100000 }), '55% of this session ran on a lighter model');
134
+ assert.equal(savingsText({ calls: 9, routed_calls: 0 }), 'no calls moved to a lighter model yet');
136
135
  assert.equal(savingsText({ calls: 3, routed_calls: 0 }, 'holdout'), 'comparison session: not routed, so Troxy can measure the difference');
137
136
  assert.equal(savingsText({ calls: 0 }), undefined);
138
137
  assert.equal(savingsText(null), undefined);
@@ -440,3 +439,24 @@ test('reportRow: surface is desktop when the app draws the session, terminal oth
440
439
  assert.equal(reportRow({ ...base, surfaces: ['terminal'] }).surface, 'terminal');
441
440
  assert.equal(reportRow({ ...base, surfaces: [] }).surface, null);
442
441
  });
442
+
443
+ test('reportRow carries the tokens compression kept out, null when none', () => {
444
+ const { reportRow } = _internal;
445
+ const usage = { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1 };
446
+ assert.equal(reportRow({ sid: 's1', usage, compressed: 4200 }).compressed_tokens, 4200);
447
+ assert.equal(reportRow({ sid: 's1', usage, compressed: 0 }).compressed_tokens, null);
448
+ assert.equal(reportRow({ sid: 's1', usage }).compressed_tokens, null);
449
+ });
450
+
451
+ test('compressedSince reports each compression once, per session', async () => {
452
+ const { compressedSince } = _internal;
453
+ const sid = 'cccccccc-1111-2222-3333-444444444444';
454
+ let log = '1000\n500\n';
455
+ const $ = { env: { get: async () => '/home/u' }, fs: { read: async (p) => { assert.ok(p.endsWith(`/.troxy/sandbox/saved/${sid}.log`)); return log; } } };
456
+ assert.equal(await compressedSince($, sid), 1500);
457
+ assert.equal(await compressedSince($, sid), 0);
458
+ log += '250\n';
459
+ assert.equal(await compressedSince($, sid), 250);
460
+ assert.equal(await compressedSince({ ...$, fs: { read: async () => { throw new Error('no file'); } } }, 'dddddddd-1111-2222'), 0);
461
+ assert.equal(await compressedSince($, '../etc/passwd'), 0);
462
+ });
@@ -223,3 +223,23 @@ test('session_id with path characters is ignored (no file outside the store)', a
223
223
  { holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
224
224
  assert.equal(fs.existsSync(path.join(dir, 'sessions')), false);
225
225
  });
226
+
227
+ test('a compression records the tokens it kept out, per session, one line each', async () => {
228
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
229
+ const sid = 'aaaaaaaa-1111-2222-3333-444444444444';
230
+ const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
231
+ const big = 'x'.repeat(8000);
232
+ await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
233
+ await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
234
+ const lines = fs.readFileSync(path.join(dir, 'saved', `${sid}.log`), 'utf8').trim().split('\n').map(Number);
235
+ assert.equal(lines.length, 2);
236
+ assert.ok(lines.every(n => n > 1000 && n <= 2000)); // about 8000 chars / 4, minus the excerpt
237
+ });
238
+
239
+ test('no session id or a small output records nothing', async () => {
240
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
241
+ const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
242
+ await buildHookResponse({ tool_response: bashResult('x'.repeat(8000)) }, opts);
243
+ await buildHookResponse({ session_id: 'bbbbbbbb-1111-2222-3333-444444444444', tool_response: bashResult('short') }, opts);
244
+ assert.equal(fs.existsSync(path.join(dir, 'saved')), false);
245
+ });