troxy-cli 1.29.47 → 1.29.48

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.47",
3
+ "version": "1.29.48",
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": {
@@ -148,11 +148,13 @@ function bar(fraction) {
148
148
  // [██████░░░░] This session: your bar lasts 1.7x longer · 5h: 19 instead of 32 · 7d: 12 instead of 20 · estimate
149
149
  function savedLine(money, rateLimits = []) {
150
150
  const paid = money && money.cost_paid_usd, kept = money && money.dollars_saved_usd;
151
- if (!(paid > 0) || !(kept > 0)) return undefined;
151
+ if (!(paid > 0)) return undefined;
152
+ const windows = rateLimits.filter(w => WINDOW_LABEL[w.kind]);
153
+ const hasMovement = windows.some(w => Math.round(w.percentUsed) >= 1);
154
+ if (!(kept > 0) && !hasMovement) return undefined;
152
155
  const scale = (paid + kept) / paid;
153
156
  const parts = [`This session: your bar lasts ${scale.toFixed(scale < 1.1 ? 2 : 1)}x longer`];
154
- const windows = rateLimits.filter(w => WINDOW_LABEL[w.kind]);
155
- if (windows.some(w => Math.round(w.percentUsed) >= 1)) {
157
+ if (hasMovement) {
156
158
  for (const w of windows) {
157
159
  const used = Math.round(w.percentUsed);
158
160
  parts.push(`${WINDOW_LABEL[w.kind]}: ${used < 1 ? 'not moved yet' : `${used} instead of ${Math.round(used * scale)}`}`);
@@ -173,8 +175,8 @@ function sessionLine(t) {
173
175
  // by the server. undefined until something was saved.
174
176
  function savingsText(s) {
175
177
  const c = (s && s.compressed_tokens) || 0, t = (s && s.trimmed_tokens) || 0;
176
- if (!(c + t > 0)) return undefined;
177
178
  const k = n => n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
179
+ // Always show, even if 0, so the band is transparent: 0 means no trimming/compression on this turn
178
180
  return `${k(c + t)} tokens saved by compression and trimming`;
179
181
  }
180
182
 
@@ -209,7 +211,7 @@ function statusText({ version, paused, totals, rateLimits = [], compactOff = fal
209
211
  : 'This session: nothing moved to a cheaper model yet');
210
212
  const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
211
213
  if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
212
- lines.push(`Past ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context a session stays on the model it is on (switching would rewrite the cache), unless a prompt needs a stronger one.`);
214
+ 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.');
213
215
  lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
214
216
  : `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
215
217
  lines.push('All sessions: dash.troxy.io');
@@ -323,30 +325,49 @@ const lastAsked = new Map();
323
325
  // The model each session last ran on and when: its cache is warm for about an hour.
324
326
  const lastSent = new Map();
325
327
  const WARM_MS = 55 * 60 * 1000;
328
+ // Sessions whose next step is a cold moment because the conversation was just compacted.
329
+ const coldNext = new Set();
330
+ // One router per session, measured: a holdout session is never routed (the proxy
331
+ // doesn't route mod sessions either), so treated vs holdout is a clean A/B.
332
+ const ARM = { plugin: 'troxy-guard', key: 'arm' };
333
+ const MOD_HOLDOUT_FRACTION = 0.1;
334
+
335
+ // Which model a step runs on. Decide only at a cold moment (first step, 55+ min
336
+ // idle, after a compaction): the cache is cold anyway, so any model is free to
337
+ // pick, at any context size. In between, hold the warm model: no switch, no
338
+ // cache rewrite. The user's own /model always wins and becomes the held model.
339
+ function stepModel({ arm, requested, switched, cold, score, warm }) {
340
+ if (arm !== 'treated' || switched) return requested;
341
+ if (cold) return pickModel(score, requested, 0, null);
342
+ const w = tierOf(warm), rq = tierOf(requested);
343
+ return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
344
+ }
326
345
 
327
346
  async function* routeStep($, e, next) {
328
347
  // Main loop only: a subagent keeps whatever model it was given.
329
348
  if (e.agentId) return yield* next(e);
330
349
  const sid = await $.session.id();
331
350
  const now = await $.clock.now();
332
- // The user picked a different model since the last turn: their choice wins
333
- // for this turn (the proxy does the same, route_reason 'user_switch').
351
+ let { value: arm } = await $.state.get(ARM);
352
+ if (!arm) { arm = Math.random() < MOD_HOLDOUT_FRACTION ? 'holdout' : 'treated'; await $.state.set(ARM, arm); }
334
353
  const prev = lastAsked.get(sid);
335
354
  lastAsked.set(sid, e.model);
336
- if (prev && prev !== e.model) decision = null;
337
- if (!decision) { lastSent.set(sid, { model: e.model, at: now }); return yield* next(e); }
338
355
  const ls = lastSent.get(sid);
339
- const model = pickModel(decision.score, e.model, decision.tokens, ls && now - ls.at < WARM_MS ? ls.model : null);
356
+ const model = (await $.store.get('paused')) ? e.model : stepModel({
357
+ arm, requested: e.model, switched: Boolean(prev && prev !== e.model),
358
+ cold: !ls || now - ls.at >= WARM_MS || coldNext.has(sid),
359
+ score: decision ? decision.score : null, warm: ls && ls.model,
360
+ });
361
+ coldNext.delete(sid);
340
362
  lastSent.set(sid, { model, at: now });
341
- if (model !== e.model) {
342
- const file = modRouteFile($.plugin.root, sid);
343
- const key = `${sid}:${e.model}>${model}`;
344
- if (file && lastNote !== key) {
345
- lastNote = key;
346
- await $.fs.write(file, JSON.stringify({ from: e.model, to: model, ts: await $.clock.now() }));
347
- }
348
- if (turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
363
+ // The daemon tags every call with the arm, plus from/to when the model changed.
364
+ const file = modRouteFile($.plugin.root, sid);
365
+ const key = `${sid}:${arm}:${e.model}>${model}`;
366
+ if (file && lastNote !== key) {
367
+ lastNote = key;
368
+ await $.fs.write(file, JSON.stringify(model === e.model ? { arm, ts: now } : { arm, from: e.model, to: model, ts: now }));
349
369
  }
370
+ if (model !== e.model && turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
350
371
  return yield* next(model === e.model ? e : { ...e, model });
351
372
  }
352
373
 
@@ -379,6 +400,11 @@ export function register(on) {
379
400
  // Routing fails open: a scorer or hook error must never break the session,
380
401
  // it just means the requested model is used as-is.
381
402
  on('prompt.submit', scorePrompt).catch(($, e, next) => { decision = null; return next(e); });
403
+ on('session.compact', async ($, e, next) => {
404
+ const r = await next(e);
405
+ if (!e.agentId) coldNext.add(await $.session.id()); // compacted: the next step is a cold moment
406
+ return r;
407
+ });
382
408
  on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
383
409
  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
384
410
  const { value: line } = await $.state.get(LINE); // subscribes the band to the line
@@ -412,4 +438,4 @@ export function register(on) {
412
438
  }
413
439
 
414
440
  // Exported for tests only - not part of the mod's runtime contract.
415
- export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep };
441
+ export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel };
@@ -1,8 +1,9 @@
1
1
  export type BarLine = string | null
2
+ export type ModArm = 'treated' | 'holdout' | null
2
3
  export type SessionSaved = { paid: number; full: number; meter: Record<string, number> }
3
4
 
4
5
  declare module 'claude-code' {
5
6
  interface PluginState {
6
- 'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine }
7
+ 'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine; arm: ModArm }
7
8
  }
8
9
  }
@@ -74,7 +74,8 @@ test('savedLine: how much longer the bar lasts, then the meter or the dollars',
74
74
  '[███░░░░░░░] This session: your bar lasts 3.8x longer · $4.53 saved of $6.17 at list price · estimate');
75
75
  assert.equal(savedLine({ cost_paid_usd: 92, dollars_saved_usd: 8 }, []),
76
76
  '[█████████░] This session: your bar lasts 1.09x longer · $8.00 saved of $100.00 at list price · estimate');
77
- assert.equal(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 0 }, meter), undefined);
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');
78
79
  assert.equal(savedLine(null, meter), undefined);
79
80
  });
80
81
 
@@ -98,7 +99,7 @@ test('statusText: version, routing, this session, plan usage, where the account
98
99
  'Troxy 1.29.42 · routing is on\n'
99
100
  + 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
100
101
  + 'Plan usage now: 5h 23% · 7d 77%\n'
101
- + 'Past 50k tokens of context a session stays on the model it is on (switching would rewrite the cache), unless a prompt needs a stronger one.\n'
102
+ + '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'
102
103
  + 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
103
104
  + 'All sessions: dash.troxy.io\n'
104
105
  + 'Type /troxy off to pause routing.');
@@ -119,12 +120,12 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
119
120
  assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
120
121
  });
121
122
 
122
- test('savingsText: shows compression + trimming together, nothing when nothing was saved', () => {
123
+ test('savingsText: shows compression + trimming together, 0 when nothing was saved', () => {
123
124
  const { savingsText } = _internal;
124
125
  assert.equal(savingsText({ compressed_tokens: 5200, trimmed_tokens: 40100 }), '45k tokens saved by compression and trimming');
125
126
  assert.equal(savingsText({ compressed_tokens: 300, trimmed_tokens: 0 }), '300 tokens saved by compression and trimming');
126
- assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }), undefined);
127
- assert.equal(savingsText(null), undefined);
127
+ assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }), '0 tokens saved by compression and trimming');
128
+ assert.equal(savingsText(null), '0 tokens saved by compression and trimming');
128
129
  });
129
130
 
130
131
  test('fetchSavings: sends the saved key and the session id; null without a key or on an error', async () => {
@@ -146,7 +147,7 @@ test('fetchSavings: sends the saved key and the session id; null without a key o
146
147
  // A fake $ just big enough for scorePrompt + routeStep: the scorer says "easy"
147
148
  // (score 0.1) and the context is small, so the mod would route down to haiku.
148
149
  function routingApi() {
149
- const kv = new Map();
150
+ const kv = new Map([['arm', 'treated']]);
150
151
  const sid = 'sess-' + Math.random().toString(36).slice(2, 10);
151
152
  return {
152
153
  state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
@@ -169,12 +170,23 @@ async function runTurn($, model) {
169
170
  return sent;
170
171
  }
171
172
 
172
- test('routeStep: a /model switch is honored for that turn, then routing resumes', async () => {
173
+ test('routeStep: a /model switch wins and becomes the held model', async () => {
173
174
  const $ = routingApi();
174
175
  const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
175
- assert.equal(await runTurn($, opus), haiku); // first turn: routed down as before
176
+ assert.equal(await runTurn($, opus), haiku); // first turn is cold: routed down
176
177
  assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
177
- assert.equal(await runTurn($, sonnet), haiku); // same choice again: routing resumes
178
+ assert.equal(await runTurn($, sonnet), sonnet); // warm on sonnet: held, no switch
179
+ });
180
+
181
+ test('stepModel: decide only at cold moments, hold in between, holdout never routes', () => {
182
+ const { stepModel, MODELS: M } = _internal;
183
+ const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.haiku };
184
+ assert.equal(stepModel({ ...base, cold: true, score: 0.5 }), M.sonnet); // cold: pick by score, any size
185
+ assert.equal(stepModel({ ...base, cold: false, score: 0.9 }), M.haiku); // warm: hold, even for a hard prompt
186
+ assert.equal(stepModel({ ...base, cold: false, score: null }), M.haiku); // warm, no score: hold
187
+ assert.equal(stepModel({ ...base, cold: true, score: null }), M.opus); // cold, no score: as asked
188
+ assert.equal(stepModel({ ...base, arm: 'holdout', cold: true, score: 0.1 }), M.opus);
189
+ assert.equal(stepModel({ ...base, switched: true, cold: false, score: 0.1 }), M.opus);
178
190
  });
179
191
 
180
192
  test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {