troxy-cli 1.30.2 → 1.30.4

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.2",
3
+ "version": "1.30.4",
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 }) {
233
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces }) {
239
234
  const rl = {};
240
235
  for (const w of rateLimits || []) {
241
236
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -248,6 +243,7 @@ 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
+ surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
251
247
  };
252
248
  }
253
249
 
@@ -256,7 +252,9 @@ async function noteStep($, result, meta) {
256
252
  if (!usage || !usage.model) return;
257
253
  let rateLimits = [];
258
254
  try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
259
- pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, ts: await $.clock.now() }));
255
+ let surfaces = [];
256
+ try { surfaces = await $.session.surfaces(); } catch {}
257
+ pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, surfaces, ts: await $.clock.now() }));
260
258
  if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
261
259
  }
262
260
 
@@ -275,10 +273,9 @@ async function flushReport($) {
275
273
  }
276
274
 
277
275
  // /troxy status: everything about this session's Troxy in one place.
278
- function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
276
+ function statusText({ version, paused, savings, rateLimits = [], compactOff = false }) {
279
277
  const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
280
- lines.push(sessionLine(totals) ? `This session: ${sessionLine(totals).replace(/^.*?This session: /, '')}`
281
- : 'This session: nothing moved to a cheaper model yet');
278
+ lines.push(`This session: ${savings || 'nothing moved to a lighter model yet'}`);
282
279
  const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
283
280
  if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
284
281
  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.');
@@ -603,8 +600,7 @@ export function register(on) {
603
600
  await $.store.set('paused', r.paused);
604
601
  if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
605
602
  const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
606
- const { value: totals } = await $.state.get(SAVED);
607
- return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
603
+ 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')) }) };
608
604
  });
609
605
  // Routing fails open: a scorer or hook error must never break the session,
610
606
  // it just means the requested model is used as-is.
@@ -621,7 +617,7 @@ export function register(on) {
621
617
  const { value: kept } = await $.state.get(SAVINGS);
622
618
  if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
623
619
  const { Text } = $.ui.resolve(e);
624
- return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note && note.text].filter(Boolean).join(' · ')}`);
620
+ return h(Text, { color: TROXY_TEAL }, `Troxy · ${[kept, line, note && note.text].filter(Boolean).join(' · ')}`);
625
621
  });
626
622
  // After the answer, so counting never delays a prompt.
627
623
  on('turn.complete', async ($, e, next) => {
@@ -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);
@@ -432,3 +431,11 @@ test('routeStep: with the effort swap on, an easy prompt goes out at low effort
432
431
  assert.equal(pendingRows().at(-1).effort, 'low');
433
432
  assert.equal(pendingRows().at(-1).effort_requested, 'high');
434
433
  });
434
+
435
+ test('reportRow: surface is desktop when the app draws the session, terminal otherwise, null in -p', () => {
436
+ const { reportRow } = _internal;
437
+ const base = { sid: 's', usage: { model: 'm' } };
438
+ assert.equal(reportRow({ ...base, surfaces: ['terminal', 'desktop'] }).surface, 'desktop');
439
+ assert.equal(reportRow({ ...base, surfaces: ['terminal'] }).surface, 'terminal');
440
+ assert.equal(reportRow({ ...base, surfaces: [] }).surface, null);
441
+ });