troxy-cli 1.30.0 → 1.30.2

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.0",
3
+ "version": "1.30.2",
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": {
@@ -181,13 +181,19 @@ function sessionLine(t) {
181
181
  Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
182
182
  }
183
183
 
184
- // Tokens Troxy kept out of the requests of this session (compression + trimming), counted
185
- // by the server. undefined until something was saved.
186
- function savingsText(s) {
187
- const c = (s && s.compressed_tokens) || 0, t = (s && s.trimmed_tokens) || 0;
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.
188
+ function savingsText(s, arm) {
189
+ 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);
188
191
  const k = n => n >= 1e6 ? `${Math.round(n / 1e5) / 10}M`.replace('.0M', 'M') : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
189
- // Always show, even if 0, so the band is transparent: 0 means no trimming/compression on this turn
190
- return `${k(c + t)} tokens not re-sent, thanks to compression and trimming`;
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;
191
197
  }
192
198
 
193
199
  // This session's savings from the server, or null when there is no key, no session id or
@@ -229,7 +235,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
229
235
  let pending = [];
230
236
  let compacting = false;
231
237
 
232
- function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits }) {
238
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked }) {
233
239
  const rl = {};
234
240
  for (const w of rateLimits || []) {
235
241
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -241,7 +247,7 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
241
247
  arm: arm || null, switch: arm ? (cold ? 'cold' : 'warm') : null, score: score == null ? null : score,
242
248
  compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
243
249
  cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
244
- rate_limits: rl,
250
+ rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
245
251
  };
246
252
  }
247
253
 
@@ -250,7 +256,7 @@ async function noteStep($, result, meta) {
250
256
  if (!usage || !usage.model) return;
251
257
  let rateLimits = [];
252
258
  try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
253
- pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits }));
259
+ pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, ts: await $.clock.now() }));
254
260
  if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
255
261
  }
256
262
 
@@ -504,6 +510,24 @@ function stepModel({ arm, requested, switched, cold, score, warm }) {
504
510
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
505
511
  }
506
512
 
513
+ // Effort swap: an easy prompt (scorer) runs at lower effort, so the model thinks and
514
+ // writes less and makes fewer tool calls. Never raised. Claude Code sends the change
515
+ // per message, which keeps the prompt cache (tested 2026-10-20: about 65 cache tokens
516
+ // written on a changed turn, same as an unchanged one). Off unless switched on
517
+ // (config effortSwap, or TROXY_EFFORT_SWAP=1 for an A/B run) until it is measured.
518
+ const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
519
+ const EFFORT_LOW_BELOW = 0.4;
520
+ const EFFORT_MEDIUM_BELOW = 0.85;
521
+ function stepEffort({ arm, requested, score, on }) {
522
+ if (!on || arm !== 'treated' || score == null || !(requested in EFFORT_RANK)) return requested;
523
+ const cap = score < EFFORT_LOW_BELOW ? 'low' : score < EFFORT_MEDIUM_BELOW ? 'medium' : null;
524
+ return cap && EFFORT_RANK[cap] < EFFORT_RANK[requested] ? cap : requested;
525
+ }
526
+ async function effortSwapOn($) {
527
+ if ((await $.env.get('TROXY_EFFORT_SWAP')) === '1') return true;
528
+ return Boolean((await troxyConfig($)).effortSwap);
529
+ }
530
+
507
531
  async function* routeStep($, e, next) {
508
532
  // Main loop only: a subagent keeps whatever model it was given.
509
533
  if (e.agentId) {
@@ -546,8 +570,13 @@ async function* routeStep($, e, next) {
546
570
  }
547
571
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
548
572
  if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
549
- const result = yield* next(model === e.model ? e : { ...e, model });
550
- try { await noteStep($, result, { requested: want, arm, cold: isCold, score: decision ? decision.score : null }); } catch {} // a missed row only thins the dashboard
573
+ let effort = e.effort;
574
+ if (!(await $.store.get('paused'))) {
575
+ try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
576
+ }
577
+ const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
578
+ const result = yield* next(sent);
579
+ try { await noteStep($, result, { requested: want, arm, cold: isCold, score: decision ? decision.score : null, effort: sent.effort, effortAsked: e.effort }); } catch {} // a missed row only thins the dashboard
551
580
  return result;
552
581
  }
553
582
 
@@ -601,7 +630,7 @@ export function register(on) {
601
630
  let savings = null;
602
631
  try {
603
632
  savings = await fetchSavings($);
604
- if (savings) await $.state.set(SAVINGS, savingsText(savings));
633
+ if (savings) await $.state.set(SAVINGS, savingsText(savings, (await $.state.get(ARM)).value) || null);
605
634
  } catch {} // a missed fetch only leaves the band as it was
606
635
  if (turn) {
607
636
  const { start, ratio } = turn;
@@ -623,4 +652,4 @@ export function register(on) {
623
652
  }
624
653
 
625
654
  // Exported for tests only - not part of the mod's runtime contract.
626
- export const _internal = { 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 };
655
+ 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 };
@@ -128,14 +128,14 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
128
128
  assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
129
129
  });
130
130
 
131
- test('savingsText: shows compression + trimming together, 0 when nothing was saved', () => {
131
+ test('savingsText: calls on a lighter model, kept tokens only when there are some, holdout says so', () => {
132
132
  const { savingsText } = _internal;
133
- assert.equal(savingsText({ compressed_tokens: 5200, trimmed_tokens: 40100 }), '45k tokens not re-sent, thanks to compression and trimming');
134
- assert.equal(savingsText({ compressed_tokens: 300, trimmed_tokens: 0 }), '300 tokens not re-sent, thanks to compression and trimming');
135
- assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }), '0 tokens not re-sent, thanks to compression and trimming');
136
- assert.equal(savingsText({ compressed_tokens: 123277, trimmed_tokens: 154512861 }), '154.6M tokens not re-sent, thanks to compression and trimming');
137
- assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 2000000 }), '2M tokens not re-sent, thanks to compression and trimming');
138
- assert.equal(savingsText(null), '0 tokens not re-sent, thanks to compression and trimming');
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');
136
+ assert.equal(savingsText({ calls: 3, routed_calls: 0 }, 'holdout'), 'comparison session: not routed, so Troxy can measure the difference');
137
+ assert.equal(savingsText({ calls: 0 }), undefined);
138
+ assert.equal(savingsText(null), undefined);
139
139
  });
140
140
 
141
141
  test('fetchSavings: sends the saved key and the session id; null without a key or on an error', async () => {
@@ -364,6 +364,7 @@ test('usage report: each request is queued with model, tokens, bar and arm, then
364
364
  const row = pendingRows().at(-1);
365
365
  assert.equal(row.model, M.sonnet); assert.equal(row.model_requested, M.opus);
366
366
  assert.equal(row.arm, 'treated'); assert.equal(row.switch, 'cold'); assert.equal(row.cache_read_tokens, 900);
367
+ assert.equal(typeof row.ts, 'number'); // when the request ran, not when the turn ended
367
368
  assert.deepEqual(row.rate_limits, { five_hour: { percent_used: 42, resets_at: '2026-10-20T10:00:00Z' } });
368
369
  assert.equal(JSON.stringify(row).includes('reply only ok'), false); // never prompt text
369
370
  await flushReport($);
@@ -402,3 +403,32 @@ test('usage report: a server error keeps the rows for the next turn', async () =
402
403
  await flushReport($);
403
404
  assert.equal(pendingRows().length, 0);
404
405
  });
406
+
407
+ test('stepEffort: easy prompts drop to low, middle ones to medium, never raised, off unless on', () => {
408
+ const { stepEffort } = _internal;
409
+ const on = { arm: 'treated', on: true };
410
+ assert.equal(stepEffort({ ...on, requested: 'high', score: 0.1 }), 'low');
411
+ assert.equal(stepEffort({ ...on, requested: 'xhigh', score: 0.6 }), 'medium');
412
+ assert.equal(stepEffort({ ...on, requested: 'high', score: 0.9 }), 'high'); // hard: kept
413
+ assert.equal(stepEffort({ ...on, requested: 'low', score: 0.6 }), 'low'); // never raised
414
+ assert.equal(stepEffort({ ...on, requested: 'medium', score: 0.6 }), 'medium');
415
+ assert.equal(stepEffort({ ...on, requested: 'high', score: null }), 'high'); // no score: no change
416
+ assert.equal(stepEffort({ ...on, requested: undefined, score: 0.1 }), undefined); // model without effort
417
+ assert.equal(stepEffort({ ...on, requested: 16000, score: 0.1 }), 16000); // a numeric budget: left alone
418
+ assert.equal(stepEffort({ arm: 'holdout', on: true, requested: 'high', score: 0.1 }), 'high');
419
+ assert.equal(stepEffort({ arm: 'treated', on: false, requested: 'high', score: 0.1 }), 'high');
420
+ });
421
+
422
+ test('routeStep: with the effort swap on, an easy prompt goes out at low effort and the row records it', async () => {
423
+ const { MODELS: M, routeStep, scorePrompt, pendingRows } = _internal;
424
+ const $ = { ...routingApi(),
425
+ env: { get: async (k) => (k === 'TROXY_EFFORT_SWAP' ? '1' : undefined) },
426
+ fs: { write: async () => {}, read: async () => { throw new Error('no config'); } } };
427
+ await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
428
+ let sent;
429
+ const gen = routeStep($, { model: M.opus, effort: 'high' }, async function* (e) { sent = e; return { usage: { model: e.model, output_tokens: 1 } }; });
430
+ for await (const _ of gen) { /* drain */ }
431
+ assert.equal(sent.effort, 'low');
432
+ assert.equal(pendingRows().at(-1).effort, 'low');
433
+ assert.equal(pendingRows().at(-1).effort_requested, 'high');
434
+ });