troxy-cli 1.30.1 → 1.30.3

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.1",
3
+ "version": "1.30.3",
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": {
@@ -235,7 +235,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
235
235
  let pending = [];
236
236
  let compacting = false;
237
237
 
238
- function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts }) {
238
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces }) {
239
239
  const rl = {};
240
240
  for (const w of rateLimits || []) {
241
241
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -247,7 +247,8 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
247
247
  arm: arm || null, switch: arm ? (cold ? 'cold' : 'warm') : null, score: score == null ? null : score,
248
248
  compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
249
249
  cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
250
- rate_limits: rl, ts,
250
+ rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
251
+ surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
251
252
  };
252
253
  }
253
254
 
@@ -256,7 +257,9 @@ async function noteStep($, result, meta) {
256
257
  if (!usage || !usage.model) return;
257
258
  let rateLimits = [];
258
259
  try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
259
- pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, ts: await $.clock.now() }));
260
+ let surfaces = [];
261
+ try { surfaces = await $.session.surfaces(); } catch {}
262
+ pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, surfaces, ts: await $.clock.now() }));
260
263
  if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
261
264
  }
262
265
 
@@ -510,6 +513,24 @@ function stepModel({ arm, requested, switched, cold, score, warm }) {
510
513
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
511
514
  }
512
515
 
516
+ // Effort swap: an easy prompt (scorer) runs at lower effort, so the model thinks and
517
+ // writes less and makes fewer tool calls. Never raised. Claude Code sends the change
518
+ // per message, which keeps the prompt cache (tested 2026-10-20: about 65 cache tokens
519
+ // written on a changed turn, same as an unchanged one). Off unless switched on
520
+ // (config effortSwap, or TROXY_EFFORT_SWAP=1 for an A/B run) until it is measured.
521
+ const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
522
+ const EFFORT_LOW_BELOW = 0.4;
523
+ const EFFORT_MEDIUM_BELOW = 0.85;
524
+ function stepEffort({ arm, requested, score, on }) {
525
+ if (!on || arm !== 'treated' || score == null || !(requested in EFFORT_RANK)) return requested;
526
+ const cap = score < EFFORT_LOW_BELOW ? 'low' : score < EFFORT_MEDIUM_BELOW ? 'medium' : null;
527
+ return cap && EFFORT_RANK[cap] < EFFORT_RANK[requested] ? cap : requested;
528
+ }
529
+ async function effortSwapOn($) {
530
+ if ((await $.env.get('TROXY_EFFORT_SWAP')) === '1') return true;
531
+ return Boolean((await troxyConfig($)).effortSwap);
532
+ }
533
+
513
534
  async function* routeStep($, e, next) {
514
535
  // Main loop only: a subagent keeps whatever model it was given.
515
536
  if (e.agentId) {
@@ -552,8 +573,13 @@ async function* routeStep($, e, next) {
552
573
  }
553
574
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
554
575
  if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
555
- const result = yield* next(model === e.model ? e : { ...e, model });
556
- try { await noteStep($, result, { requested: want, arm, cold: isCold, score: decision ? decision.score : null }); } catch {} // a missed row only thins the dashboard
576
+ let effort = e.effort;
577
+ if (!(await $.store.get('paused'))) {
578
+ try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
579
+ }
580
+ const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
581
+ const result = yield* next(sent);
582
+ 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
557
583
  return result;
558
584
  }
559
585
 
@@ -629,4 +655,4 @@ export function register(on) {
629
655
  }
630
656
 
631
657
  // Exported for tests only - not part of the mod's runtime contract.
632
- 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 };
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 };
@@ -403,3 +403,40 @@ test('usage report: a server error keeps the rows for the next turn', async () =
403
403
  await flushReport($);
404
404
  assert.equal(pendingRows().length, 0);
405
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
+ });
435
+
436
+ test('reportRow: surface is desktop when the app draws the session, terminal otherwise, null in -p', () => {
437
+ const { reportRow } = _internal;
438
+ const base = { sid: 's', usage: { model: 'm' } };
439
+ assert.equal(reportRow({ ...base, surfaces: ['terminal', 'desktop'] }).surface, 'desktop');
440
+ assert.equal(reportRow({ ...base, surfaces: ['terminal'] }).surface, 'terminal');
441
+ assert.equal(reportRow({ ...base, surfaces: [] }).surface, null);
442
+ });