troxy-cli 1.29.60 → 1.29.62

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.29.60",
3
+ "version": "1.29.62",
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": {
@@ -156,25 +156,17 @@ function bar(fraction) {
156
156
  }
157
157
 
158
158
  // One line, most important first, e.g.
159
- // [██████░░░░] This session: your bar lasts 1.7x longer · 5h: 19 instead of 32 · 7d: 12 instead of 20 · estimate
159
+ // This session: savings under measurement · 5h: 19% used · 7d: 12% used
160
160
  function savedLine(money, rateLimits = []) {
161
- const paid = money && money.cost_paid_usd, kept = money && money.dollars_saved_usd;
161
+ const paid = money && money.cost_paid_usd;
162
162
  if (!(paid > 0)) return undefined;
163
- const windows = rateLimits.filter(w => WINDOW_LABEL[w.kind]);
164
- const hasMovement = windows.some(w => Math.round(w.percentUsed) >= 1);
165
- if (!(kept > 0) && !hasMovement) return undefined;
166
- const scale = (paid + kept) / paid;
167
- const parts = [`This session: your bar lasts ${scale.toFixed(scale < 1.1 ? 2 : 1)}x longer`];
168
- if (hasMovement) {
169
- for (const w of windows) {
170
- const used = Math.round(w.percentUsed);
171
- parts.push(`${WINDOW_LABEL[w.kind]}: ${used < 1 ? 'not moved yet' : `${used} instead of ${Math.round(used * scale)}`}`);
172
- }
173
- } else {
174
- parts.push('meter not moved yet');
175
- }
176
- parts.push('estimate');
177
- return `${bar(1 / scale)} ${parts.join(' · ')}`;
163
+ // No "N times longer" claim: the 2026-10-15/16 fixed-job A/Bs (Troxy on vs off, bar read
164
+ // from Claude Code) found routing used MORE bar, so the estimate is not shown until a
165
+ // measured replacement exists.
166
+ const moved = rateLimits.filter(w => WINDOW_LABEL[w.kind] && Math.round(w.percentUsed) >= 1);
167
+ if (!(money.dollars_saved_usd > 0) && !moved.length) return undefined; // nothing routed or cleared yet
168
+ return ['This session: savings under measurement',
169
+ ...moved.map(w => `${WINDOW_LABEL[w.kind]}: ${Math.round(w.percentUsed)}% used`)].join(' · ');
178
170
  }
179
171
 
180
172
  // t.kept: what the content Troxy cleared or compressed would have cost (server's
@@ -440,13 +432,17 @@ const held = new Map();
440
432
 
441
433
  // Which model a step runs on. At a cold moment (first step, 55+ min idle, after a
442
434
  // compaction) any model is free to pick. While warm, hold the model, except at the
443
- // first step of a new prompt once it has held HOLD_PROMPTS prompts: then re-pick by
444
- // score, down or up. The user's own /model always wins and becomes the held model.
435
+ // first step of a new prompt: up at once, down once it has held HOLD_PROMPTS prompts,
436
+ // re-picking by score. The user's own /model always wins and becomes the held model.
445
437
  function stepModel({ arm, requested, switched, cold, score, warm, newPrompt, heldPrompts, tokens }) {
446
438
  if (arm !== 'treated' || switched) return requested;
447
439
  if (cold) return pickModel(score, requested, 0, null);
448
- if (newPrompt && score !== null && heldPrompts >= HOLD_PROMPTS && tokens <= WARM_SWITCH_MAX_TOKENS) {
449
- return pickModel(score, requested, 0, null);
440
+ if (newPrompt && score !== null && tokens <= WARM_SWITCH_MAX_TOKENS) {
441
+ const pick = pickModel(score, requested, 0, null);
442
+ // Up is immediate: a cheap model chosen for an easy first prompt must not keep
443
+ // running a harder one (the 2026-10-15 A/B: Haiku held a whole coding session and
444
+ // made 2-4x the calls). Down still waits HOLD_PROMPTS, since it re-writes the cache.
445
+ if (RANK[tierOf(pick)] > RANK[tierOf(warm)] || heldPrompts >= HOLD_PROMPTS) return pick;
450
446
  }
451
447
  const w = tierOf(warm), rq = tierOf(requested);
452
448
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
@@ -63,20 +63,16 @@ test('real subprocess: a real Bash command still runs normally through the mod (
63
63
  }
64
64
  });
65
65
 
66
- test('savedLine: how much longer the bar lasts, then the meter', () => {
66
+ 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: your bar lasts 1.7x longer · 5h: 19 instead of 32 · 7d: 12 instead of 20 · estimate');
70
+ 'This session: savings under measurement · 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: your bar lasts 1.09x longer · 5h: 12 instead of 13 · 7d: not moved yet · estimate');
73
- assert.equal(savedLine({ cost_paid_usd: 1.64, dollars_saved_usd: 4.53 }, [{ kind: 'five_hour', percentUsed: 0.3 }]),
74
- '[███░░░░░░░] This session: your bar lasts 3.8x longer · meter not moved yet · estimate');
75
- assert.equal(savedLine({ cost_paid_usd: 92, dollars_saved_usd: 8 }, []),
76
- '[█████████░] This session: your bar lasts 1.09x longer · meter not moved yet · estimate');
77
- assert.equal(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 0 }, meter),
78
- '[██████████] This session: your bar lasts 1.00x longer · 5h: 19 instead of 19 · 7d: 12 instead of 12 · estimate');
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');
79
74
  assert.equal(savedLine(null, meter), undefined);
75
+ assert.doesNotMatch(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 50 }, meter), /longer|instead of/);
80
76
  });
81
77
 
82
78
  test('addTurn: a session total from its own turns, a routed one counted at the asked-for price', () => {
@@ -87,11 +83,11 @@ test('addTurn: a session total from its own turns, a routed one counted at the a
87
83
  // Not routed; the 5h window reset mid-turn, so its move counts as 0, not negative.
88
84
  t = addTurn(t, { cost: 1.5, meter: { five_hour: 13, seven_day: 5 } }, { cost: 2, meter: { five_hour: 1, seven_day: 6 } }, 1);
89
85
  assert.deepEqual(t, { paid: 1, full: 1.5, meter: { five_hour: 3, seven_day: 2 } });
90
- assert.equal(sessionLine(t), '[███████░░░] This session: your bar lasts 1.5x longer · 5h: 3 instead of 5 · 7d: 2 instead of 3 · estimate');
86
+ assert.equal(sessionLine(t), 'This session: savings under measurement · 5h: 3% used · 7d: 2% used');
91
87
  // Cleared/compressed content counts too, and survives the next turn: $1 paid, $0.5 swap + $1.5 kept = 3x.
92
88
  t = addTurn({ ...t, kept: 1.5 }, { cost: 2, meter: {} }, { cost: 2, meter: {} }, 1);
93
89
  assert.equal(t.kept, 1.5);
94
- assert.match(sessionLine(t), /your bar lasts 3\.0x longer · 5h: 3 instead of 9/);
90
+ assert.match(sessionLine(t), /savings under measurement · 5h: 3% used/);
95
91
  // Server total of a session already 28 dollars deep: only growth after the first reading counts.
96
92
  const { withKept } = _internal;
97
93
  let k = { paid: 1, full: 1, meter: {} };
@@ -109,7 +105,7 @@ test('statusText: version, routing, this session, plan usage, where the account
109
105
  const totals = { paid: 0.03, full: 0.12, meter: {} };
110
106
  assert.equal(statusText({ version: '1.29.42', paused: false, totals, rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
111
107
  'Troxy 1.29.42 · routing is on\n'
112
- + 'This session: your bar lasts 4.0x longer · meter not moved yet · estimate\n'
108
+ + 'This session: savings under measurement\n'
113
109
  + 'Plan usage now: 5h 23% · 7d 77%\n'
114
110
  + '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'
115
111
  + 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
@@ -215,6 +211,18 @@ test('stepModel: warm switch at a new prompt only after the hold, and under the
215
211
  assert.equal(stepModel({ ...base, arm: 'holdout', newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus);
216
212
  });
217
213
 
214
+ test('stepModel: a harder prompt upgrades a cheap warm session at once, a lighter one waits', () => {
215
+ const { stepModel, MODELS: M, HOLD_PROMPTS } = _internal;
216
+ const base = { arm: 'treated', requested: M.opus, switched: false, cold: false, warm: M.haiku, newPrompt: true, tokens: 5000 };
217
+ assert.equal(stepModel({ ...base, score: 0.9, heldPrompts: 0 }), M.opus); // hard prompt on haiku: up now
218
+ assert.equal(stepModel({ ...base, score: 0.5, heldPrompts: 0 }), M.sonnet);
219
+ assert.equal(stepModel({ ...base, score: 0.1, heldPrompts: 0 }), M.haiku); // still easy: stay
220
+ assert.equal(stepModel({ ...base, warm: M.opus, score: 0.1, heldPrompts: 0 }), M.opus); // down waits
221
+ assert.equal(stepModel({ ...base, warm: M.opus, score: 0.1, heldPrompts: HOLD_PROMPTS }), M.haiku); // ...for the hold
222
+ assert.equal(stepModel({ ...base, score: 0.9, heldPrompts: 0, tokens: 300000 }), M.haiku); // too big to re-write
223
+ assert.equal(stepModel({ ...base, score: 0.9, heldPrompts: 0, newPrompt: false }), M.haiku); // mid-turn: hold
224
+ });
225
+
218
226
  test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
219
227
  const { pickModel, MODELS } = _internal;
220
228
  const big = 80000;