troxy-cli 1.30.8 → 1.30.10

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.8",
3
+ "version": "1.30.10",
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": {
@@ -56,7 +56,7 @@ async function failClosed($, e, next, err) {
56
56
  // to every step of its turn, so the cache prefix stays stable within a turn.
57
57
 
58
58
  const MODELS = {
59
- haiku: 'claude-haiku-4-5-20251001',
59
+ haiku: 'claude-haiku-5-5',
60
60
  sonnet: 'claude-sonnet-5-5',
61
61
  opus: 'claude-opus-5-5',
62
62
  };
@@ -67,6 +67,10 @@ const RANK = { haiku: 0, sonnet: 1, opus: 2 };
67
67
  // pick flipped between Sonnet and Opus. Sonnet is the default; only a clearly hard
68
68
  // prompt (above 0.85) keeps the requested model.
69
69
  const BANDS = [[0.85, 'sonnet']];
70
+ // Haiku band, off unless switched on (config haikuBand, or TROXY_HAIKU_BAND=1 for an A/B run):
71
+ // an easy prompt from a Sonnet session goes to Haiku 5.5. The 10-15 rejection was Haiku 4.5.
72
+ // Sonnet sessions only; Opus keeps the bands above.
73
+ const HAIKU_BANDS = [[0.2, 'haiku']];
70
74
  const ROUTE_CONTEXT_FLOOR_TOKENS = 50000;
71
75
  const SCORE_TIMEOUT_MS = 3000;
72
76
  const SCORER_PROMPT_CHARS = 4000;
@@ -104,7 +108,7 @@ function asRequest(text) {
104
108
  return `<request>\n${String(text || '').replace(/<\/request>/gi, '</ request>')}\n</request>`;
105
109
  }
106
110
 
107
- function pickModel(score, requested, contextTokens, warm) {
111
+ function pickModel(score, requested, contextTokens, warm, haiku = false) {
108
112
  const rq = tierOf(requested);
109
113
  if (rq === null) return requested; // unknown family (e.g. a newer tier): leave it alone
110
114
  // No score (scorer failed or echoed the prompt): stay on the warm model at any
@@ -114,7 +118,7 @@ function pickModel(score, requested, contextTokens, warm) {
114
118
  return w !== null && RANK[w] <= RANK[rq] ? warm : requested;
115
119
  }
116
120
  let tier = rq;
117
- for (const [limit, name] of BANDS) {
121
+ for (const [limit, name] of (haiku && rq === 'sonnet' ? HAIKU_BANDS : BANDS)) {
118
122
  if (score <= limit) { if (RANK[name] < RANK[rq]) tier = name; break; }
119
123
  }
120
124
  // The cache is per model: the first prompt on a new model re-writes the whole
@@ -522,9 +526,9 @@ async function rememberAsked($, sid, want, ran) {
522
526
  // compaction) the cache is gone anyway, so pick by score. Otherwise hold the model the
523
527
  // cache is warm on: every switch re-writes the whole conversation (2026-10-16 A/B: 4-6
524
528
  // switches per session cost more bar than routing saved). /model always wins.
525
- function stepModel({ arm, requested, switched, cold, score, warm }) {
529
+ function stepModel({ arm, requested, switched, cold, score, warm, haiku = false }) {
526
530
  if (arm !== 'treated' || switched) return requested;
527
- if (cold) return pickModel(score, requested, 0, null);
531
+ if (cold) return pickModel(score, requested, 0, null, haiku);
528
532
  const w = tierOf(warm), rq = tierOf(requested);
529
533
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
530
534
  }
@@ -547,6 +551,11 @@ async function effortSwapOn($) {
547
551
  return Boolean((await troxyConfig($)).effortSwap);
548
552
  }
549
553
 
554
+ async function haikuBandOn($) {
555
+ if ((await $.env.get('TROXY_HAIKU_BAND')) === '1') return true;
556
+ return Boolean((await troxyConfig($)).haikuBand);
557
+ }
558
+
550
559
  async function* routeStep($, e, next) {
551
560
  // Main loop only: a subagent keeps whatever model it was given.
552
561
  if (e.agentId) {
@@ -575,6 +584,7 @@ async function* routeStep($, e, next) {
575
584
  arm, requested: want, switched,
576
585
  cold: isCold,
577
586
  score: decision ? decision.score : null, warm: ls && ls.model,
587
+ haiku: await haikuBandOn($).catch(() => false),
578
588
  });
579
589
  coldNext.delete(sid);
580
590
  lastSent.set(sid, { model, at: now });
@@ -171,6 +171,9 @@ async function readStdin(timeoutMs) {
171
171
  // (`troxy posttooluse-hook`, registered via patchClaudeCodeHooks in
172
172
  // init.js) and, below, by this file's own direct-execution guard.
173
173
  export async function runPostToolUseHook() {
174
+ // Off by default: the 2026-10-22 split A/B showed output compression alone made the model
175
+ // re-run and re-read (about 4.5x the tool calls) and cost more bar. TROXY_COMPRESS=1 re-enables it.
176
+ if (process.env.TROXY_COMPRESS !== '1') process.exit(0);
174
177
  try {
175
178
  const raw = await readStdin(STDIN_TIMEOUT_MS);
176
179
  const input = JSON.parse(raw);
@@ -114,6 +114,7 @@ test('end-to-end: real init -> real hook subprocess -> real recall, and real fai
114
114
  ...process.env,
115
115
  HOME: tmpHome,
116
116
  PATH: '/usr/bin:/bin',
117
+ TROXY_COMPRESS: '1',
117
118
  };
118
119
 
119
120
  let stdout = '';
@@ -90,3 +90,13 @@ test('estimateTokens: counts the prompt being submitted, not just the context so
90
90
  // a first-prompt of ~60k tokens must trip the floor
91
91
  assert.equal(pickModel(0.05, MODELS.opus, estimateTokens(0, 'x'.repeat(240000))), MODELS.opus);
92
92
  });
93
+
94
+ test('pickModel: haiku band (flag) sends an easy Sonnet prompt to Haiku 5.5, nothing else changes', () => {
95
+ assert.equal(MODELS.haiku, 'claude-haiku-5-5');
96
+ assert.equal(pickModel(0.1, MODELS.sonnet, 1000, null, true), MODELS.haiku);
97
+ assert.equal(pickModel(0.2, MODELS.sonnet, 1000, null, true), MODELS.haiku);
98
+ assert.equal(pickModel(0.3, MODELS.sonnet, 1000, null, true), MODELS.sonnet);
99
+ assert.equal(pickModel(0.1, MODELS.sonnet, 1000, null, false), MODELS.sonnet); // off by default
100
+ assert.equal(pickModel(0.1, MODELS.opus, 1000, null, true), MODELS.sonnet); // Opus keeps its bands
101
+ assert.equal(pickModel(0.1, MODELS.sonnet, 60000, null, true), MODELS.sonnet); // 50k floor still holds
102
+ });
@@ -255,3 +255,19 @@ test('the hook records the tokens fetched back with troxy_recall, per session, a
255
255
  await buildHookResponse({ tool_name: 'mcp__troxy__troxy_recall', tool_response: [{ type: 'text', text: 'z'.repeat(800) }] }, { storeDir: dir });
256
256
  assert.equal(fs.readdirSync(path.join(dir, 'recalled')).length, 1);
257
257
  });
258
+
259
+ test('posttooluse-hook is a no-op unless TROXY_COMPRESS=1', () => {
260
+ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-home-'));
261
+ try {
262
+ const run = (extra) => execFileSync(process.execPath, [troxyBin, 'posttooluse-hook'], {
263
+ input: JSON.stringify({ session_id: 's1', tool_name: 'Bash', tool_response: bashResult('x'.repeat(5000)) }),
264
+ env: { ...process.env, HOME: tmpHome, TROXY_COMPRESS: '', ...extra },
265
+ encoding: 'utf8',
266
+ });
267
+ assert.equal(run({}), '');
268
+ // Enabled: either compressed (treated arm) or left alone (holdout arm), never an error.
269
+ assert.doesNotThrow(() => run({ TROXY_COMPRESS: '1' }));
270
+ } finally {
271
+ fs.rmSync(tmpHome, { recursive: true, force: true });
272
+ }
273
+ });