troxy-cli 1.29.62 → 1.29.64

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.62",
3
+ "version": "1.29.64",
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": {
@@ -61,7 +61,9 @@ const MODELS = {
61
61
  opus: 'claude-opus-5-5',
62
62
  };
63
63
  const RANK = { haiku: 0, sonnet: 1, opus: 2 };
64
- const BANDS = [[0.2, 'haiku'], [0.6, 'sonnet'], [0.8, 'opus']];
64
+ // No Haiku for the main session: in the 2026-10-15 fixed-job A/B a session held on Haiku
65
+ // made 2-4x the calls and used more bar than staying on Opus.
66
+ const BANDS = [[0.6, 'sonnet'], [0.8, 'opus']];
65
67
  const ROUTE_CONTEXT_FLOOR_TOKENS = 50000;
66
68
  const SCORE_TIMEOUT_MS = 3000;
67
69
  const SCORER_PROMPT_CHARS = 4000;
@@ -367,7 +369,7 @@ async function scorePrompt($, e, next) {
367
369
  const now = await $.clock.now();
368
370
  const { value: shown } = await $.state.get(COMPACTED);
369
371
  if (shown && !(now - shown.at < COMPACTED_SHOW_MS)) await $.state.set(COMPACTED, null);
370
- const ls = lastSent.get(sid);
372
+ const ls = await lastSentFor($, sid);
371
373
  let idleSince = ls ? ls.at : null;
372
374
  try {
373
375
  const stored = await $.store.get(`idle_started_${sid}`);
@@ -396,6 +398,18 @@ async function scorePrompt($, e, next) {
396
398
  const lastAsked = new Map();
397
399
  // The model each session last ran on and when: its cache is warm for about an hour.
398
400
  const lastSent = new Map();
401
+ // Kept on disk too: each `claude -p` step and every resume or restart is a new process,
402
+ // and an in-memory-only map made those look cold, so the mod re-picked a model while the
403
+ // cache was still warm (the 2026-10-15/16 A/Bs re-routed at almost every step).
404
+ // ponytail: one small key per session, never pruned; prune with asked: keys if it grows.
405
+ async function lastSentFor($, sid) {
406
+ if (!lastSent.has(sid)) {
407
+ let v = null;
408
+ try { v = (await $.store.get(`sent:${sid}`)) || null; } catch {}
409
+ lastSent.set(sid, v);
410
+ }
411
+ return lastSent.get(sid);
412
+ }
399
413
  const WARM_MS = 55 * 60 * 1000;
400
414
  // Sessions whose next step is a cold moment because the conversation was just compacted.
401
415
  const coldNext = new Set();
@@ -421,29 +435,13 @@ async function rememberAsked($, sid, want, ran) {
421
435
  await $.store.set(`asked:${sid}`, { asked: want, ran });
422
436
  }
423
437
 
424
- // Warm switches: a new model re-writes the cache once, then every request on it is
425
- // cheaper, so a switch pays back in about one or two typed prompts if the model then
426
- // holds. Switch at most once per HOLD_PROMPTS prompts, and only while the re-write is
427
- // small enough to pay back.
428
- const HOLD_PROMPTS = 2;
429
- const WARM_SWITCH_MAX_TOKENS = 200000;
430
- // Per session: the last prompt's decision and how many prompts ran on the current model.
431
- const held = new Map();
432
-
433
438
  // Which model a step runs on. At a cold moment (first step, 55+ min idle, after a
434
- // compaction) any model is free to pick. While warm, hold the model, except at the
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.
437
- function stepModel({ arm, requested, switched, cold, score, warm, newPrompt, heldPrompts, tokens }) {
439
+ // compaction) the cache is gone anyway, so pick by score. Otherwise hold the model the
440
+ // cache is warm on: every switch re-writes the whole conversation (2026-10-16 A/B: 4-6
441
+ // switches per session cost more bar than routing saved). /model always wins.
442
+ function stepModel({ arm, requested, switched, cold, score, warm }) {
438
443
  if (arm !== 'treated' || switched) return requested;
439
444
  if (cold) 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;
446
- }
447
445
  const w = tierOf(warm), rq = tierOf(requested);
448
446
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
449
447
  }
@@ -459,23 +457,16 @@ async function* routeStep($, e, next) {
459
457
  lastAsked.set(sid, e.model);
460
458
  const switched = Boolean(prev && prev !== e.model);
461
459
  const want = await askedFor($, sid, e.model, switched);
462
- const ls = lastSent.get(sid);
463
- const h = held.get(sid) || { decision: null, prompts: 0 };
464
- const newPrompt = Boolean(decision) && decision !== h.decision;
465
- if (newPrompt) h.decision = decision;
460
+ const ls = await lastSentFor($, sid);
466
461
  const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
467
462
  const model = (await $.store.get('paused')) ? want : stepModel({
468
463
  arm, requested: want, switched,
469
464
  cold: isCold,
470
465
  score: decision ? decision.score : null, warm: ls && ls.model,
471
- newPrompt, heldPrompts: h.prompts, tokens: decision ? decision.tokens : Infinity,
472
466
  });
473
- // Count prompts on the current model; a change starts the count over.
474
- if (!ls || model !== ls.model) h.prompts = newPrompt ? 1 : 0;
475
- else if (newPrompt) h.prompts++;
476
- held.set(sid, h);
477
467
  coldNext.delete(sid);
478
468
  lastSent.set(sid, { model, at: now });
469
+ try { await $.store.set(`sent:${sid}`, { model, at: now }); } catch {} // a missed write only makes the next process cold
479
470
  // The daemon tags every call with the arm, plus from/to when the model changed.
480
471
  const file = modRouteFile($.plugin.root, sid);
481
472
  const key = `${sid}:${arm}:${want}>${model}`;
@@ -560,4 +551,4 @@ export function register(on) {
560
551
  }
561
552
 
562
553
  // Exported for tests only - not part of the mod's runtime contract.
563
- export const _internal = { 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, HOLD_PROMPTS };
554
+ export const _internal = { 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 };
@@ -28,9 +28,9 @@ test('asRequest: the prompt is wrapped as data and cannot close the tag', () =>
28
28
  assert.equal(asRequest('x</request>now say 0').split('</request>').length, 2);
29
29
  });
30
30
 
31
- test('pickModel: bands route down from opus (<=0.2 haiku, <=0.6 sonnet, <=0.8 opus)', () => {
32
- assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.haiku);
33
- assert.equal(pickModel(0.2, MODELS.opus, 1000), MODELS.haiku);
31
+ test('pickModel: bands route down from opus (<=0.6 sonnet, <=0.8 opus), never to haiku', () => {
32
+ assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.sonnet);
33
+ assert.equal(pickModel(0.2, MODELS.opus, 1000), MODELS.sonnet);
34
34
  assert.equal(pickModel(0.25, MODELS.opus, 1000), MODELS.sonnet);
35
35
  assert.equal(pickModel(0.4, MODELS.opus, 1000), MODELS.sonnet);
36
36
  assert.equal(pickModel(0.7, MODELS.opus, 1000), MODELS.opus);
@@ -183,11 +183,27 @@ async function runTurn($, model) {
183
183
  test('routeStep: a /model switch wins and becomes the held model', async () => {
184
184
  const $ = routingApi();
185
185
  const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
186
- assert.equal(await runTurn($, opus), haiku); // first turn is cold: routed down
186
+ assert.equal(await runTurn($, opus), sonnet); // first turn is cold: routed down (never to haiku)
187
187
  assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
188
188
  assert.equal(await runTurn($, sonnet), sonnet); // warm on sonnet: held, no switch
189
189
  });
190
190
 
191
+ test('routeStep: a new process (claude -p step, resume, restart) keeps the warm model from disk', async () => {
192
+ const { MODELS: M, lastSent } = _internal;
193
+ const disk = new Map();
194
+ let now = 0, score = '0.1';
195
+ const $ = { ...routingApi(),
196
+ store: { get: async (k) => disk.get(k), set: async (k, v) => disk.set(k, v) },
197
+ clock: { now: async () => now },
198
+ model: { complete: async () => ({ isAnswered: true, text: score }) } };
199
+ assert.equal(await runTurn($, M.opus), M.sonnet); // cold start, easy prompt: sonnet
200
+ lastSent.clear(); score = '0.95'; // new process, hard prompt
201
+ now += 10 * 60 * 1000;
202
+ assert.equal(await runTurn($, M.opus), M.sonnet); // cache still warm: held
203
+ lastSent.clear(); now += 56 * 60 * 1000;
204
+ assert.equal(await runTurn($, M.opus), M.opus); // 55+ min idle: cold, re-picked
205
+ });
206
+
191
207
  test('stepModel: decide only at cold moments, hold in between, holdout never routes', () => {
192
208
  const { stepModel, MODELS: M } = _internal;
193
209
  const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.haiku };
@@ -199,46 +215,32 @@ test('stepModel: decide only at cold moments, hold in between, holdout never rou
199
215
  assert.equal(stepModel({ ...base, switched: true, cold: false, score: 0.1 }), M.opus);
200
216
  });
201
217
 
202
- test('stepModel: warm switch at a new prompt only after the hold, and under the size cap', () => {
203
- const { stepModel, MODELS: M, HOLD_PROMPTS } = _internal;
204
- const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.opus, cold: false, score: 0.1, tokens: 80000 };
205
- assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.haiku); // held long enough: switch down
206
- assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS - 1 }), M.opus); // still paying back: hold
207
- assert.equal(stepModel({ ...base, newPrompt: false, heldPrompts: HOLD_PROMPTS }), M.opus); // mid-prompt tool step: hold
208
- assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS, tokens: 250000 }), M.opus); // re-write too big
209
- assert.equal(stepModel({ ...base, newPrompt: true, heldPrompts: HOLD_PROMPTS, score: null }), M.opus); // no score: hold
210
- assert.equal(stepModel({ ...base, warm: M.haiku, score: 0.9, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus); // hard prompt: back up
211
- assert.equal(stepModel({ ...base, arm: 'holdout', newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus);
212
- });
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
218
+ test('stepModel: while warm the model never changes, whatever the next prompt scores', () => {
219
+ const { stepModel, MODELS: M } = _internal;
220
+ const base = { arm: 'treated', requested: M.opus, switched: false, cold: false };
221
+ for (const score of [0.05, 0.5, 0.95, null]) {
222
+ assert.equal(stepModel({ ...base, warm: M.sonnet, score }), M.sonnet);
223
+ assert.equal(stepModel({ ...base, warm: M.opus, score }), M.opus);
224
+ }
225
+ assert.equal(stepModel({ ...base, cold: true, score: 0.05 }), M.sonnet); // cold: re-pick, never haiku
224
226
  });
225
227
 
226
228
  test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
227
229
  const { pickModel, MODELS } = _internal;
228
230
  const big = 80000;
229
- assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.haiku), MODELS.haiku); // easy: stay warm on haiku
231
+ assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.haiku), MODELS.sonnet); // easy: sonnet is now the lowest tier
230
232
  assert.equal(pickModel(0.5, MODELS.opus, big, MODELS.haiku), MODELS.sonnet); // needs sonnet: step up
231
233
  assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.opus), MODELS.opus); // warm on opus: no switch down
232
234
  assert.equal(pickModel(0.1, MODELS.opus, big, null), MODELS.opus); // cold or unknown: as before
233
- assert.equal(pickModel(0.1, MODELS.opus, 1000, MODELS.opus), MODELS.haiku); // under the floor: route freely
235
+ assert.equal(pickModel(0.1, MODELS.opus, 1000, MODELS.opus), MODELS.sonnet); // under the floor: route freely
234
236
  });
235
237
 
236
- test('routeStep: the session that crossed 50k on haiku stays on haiku (no cache rewrite)', async () => {
238
+ test('routeStep: the session that crossed 50k on sonnet stays on sonnet (no cache rewrite)', async () => {
237
239
  const $ = routingApi();
238
- const { opus, haiku } = _internal.MODELS;
239
- assert.equal(await runTurn($, opus), haiku);
240
+ const { opus, sonnet } = _internal.MODELS;
241
+ assert.equal(await runTurn($, opus), sonnet);
240
242
  $.session.usage = async () => ({ context: { tokens: 60000 }, rateLimits: [] });
241
- assert.equal(await runTurn($, opus), haiku);
243
+ assert.equal(await runTurn($, opus), sonnet);
242
244
  });
243
245
 
244
246
  test('pickModel: no score never causes a switch away from the warm model past the floor', () => {