troxy-cli 1.30.1 → 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.1",
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": {
@@ -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 }) {
239
239
  const rl = {};
240
240
  for (const w of rateLimits || []) {
241
241
  if (!WINDOW_LABEL[w.kind]) continue;
@@ -247,7 +247,7 @@ 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
251
  };
252
252
  }
253
253
 
@@ -510,6 +510,24 @@ function stepModel({ arm, requested, switched, cold, score, warm }) {
510
510
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
511
511
  }
512
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
+
513
531
  async function* routeStep($, e, next) {
514
532
  // Main loop only: a subagent keeps whatever model it was given.
515
533
  if (e.agentId) {
@@ -552,8 +570,13 @@ async function* routeStep($, e, next) {
552
570
  }
553
571
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
554
572
  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
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
557
580
  return result;
558
581
  }
559
582
 
@@ -629,4 +652,4 @@ export function register(on) {
629
652
  }
630
653
 
631
654
  // 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 };
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 };
@@ -403,3 +403,32 @@ 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
+ });