troxy-cli 1.30.9 → 1.30.11

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.9",
3
+ "version": "1.30.11",
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,15 @@ 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']];
74
+ // A Haiku session moves up to Sonnet, once and for good, when a later prompt scores above
75
+ // this or the context passes the Haiku price break (Haiku 5.5 is 5x dearer past 100k tokens).
76
+ // Never back down: every switch re-writes the cache.
77
+ const HAIKU_ESCALATE_ABOVE = 0.6;
78
+ const HAIKU_MAX_TOKENS = 100000;
70
79
  const ROUTE_CONTEXT_FLOOR_TOKENS = 50000;
71
80
  const SCORE_TIMEOUT_MS = 3000;
72
81
  const SCORER_PROMPT_CHARS = 4000;
@@ -104,7 +113,7 @@ function asRequest(text) {
104
113
  return `<request>\n${String(text || '').replace(/<\/request>/gi, '</ request>')}\n</request>`;
105
114
  }
106
115
 
107
- function pickModel(score, requested, contextTokens, warm) {
116
+ function pickModel(score, requested, contextTokens, warm, haiku = false) {
108
117
  const rq = tierOf(requested);
109
118
  if (rq === null) return requested; // unknown family (e.g. a newer tier): leave it alone
110
119
  // No score (scorer failed or echoed the prompt): stay on the warm model at any
@@ -114,7 +123,7 @@ function pickModel(score, requested, contextTokens, warm) {
114
123
  return w !== null && RANK[w] <= RANK[rq] ? warm : requested;
115
124
  }
116
125
  let tier = rq;
117
- for (const [limit, name] of BANDS) {
126
+ for (const [limit, name] of (haiku && rq === 'sonnet' ? HAIKU_BANDS : BANDS)) {
118
127
  if (score <= limit) { if (RANK[name] < RANK[rq]) tier = name; break; }
119
128
  }
120
129
  // The cache is per model: the first prompt on a new model re-writes the whole
@@ -144,7 +153,8 @@ function troxyCommand(args, paused) {
144
153
  // List $/MTok in (output keeps the same ratios) times usage-bar points per list dollar,
145
154
  // measured from the bar's own readings (usage-audit bar_weights.py, 2026-10-13). The bar
146
155
  // charges Haiku more per dollar than list prices imply; same weights as the dashboard.
147
- const PRICE = { haiku: 1 * 4.45, sonnet: 2 * 3.19, opus: 4 * 2.80 };
156
+ // Haiku 5.5 ($0.10 in): 5.2 points per list dollar puts it 12x under Sonnet per token, as measured 2026-10-22.
157
+ const PRICE = { haiku: 0.1 * 5.2, sonnet: 2 * 3.19, opus: 4 * 2.80 };
148
158
  const WINDOW_LABEL = { five_hour: '5h', seven_day: '7d' };
149
159
  // Drawn in Troxy's teal: the status line can't be colored and shows as a yellow warning.
150
160
  const LINE = { plugin: 'troxy-guard', key: 'line' };
@@ -522,10 +532,12 @@ async function rememberAsked($, sid, want, ran) {
522
532
  // compaction) the cache is gone anyway, so pick by score. Otherwise hold the model the
523
533
  // cache is warm on: every switch re-writes the whole conversation (2026-10-16 A/B: 4-6
524
534
  // switches per session cost more bar than routing saved). /model always wins.
525
- function stepModel({ arm, requested, switched, cold, score, warm }) {
535
+ function stepModel({ arm, requested, switched, cold, score, warm, haiku = false, tokens = 0 }) {
526
536
  if (arm !== 'treated' || switched) return requested;
527
- if (cold) return pickModel(score, requested, 0, null);
537
+ if (cold) return pickModel(score, requested, 0, null, haiku && tokens <= HAIKU_MAX_TOKENS);
528
538
  const w = tierOf(warm), rq = tierOf(requested);
539
+ // ponytail: tokens is read at the prompt, so a turn that grows past 100k moves up at the next prompt.
540
+ if (w === 'haiku' && rq === 'sonnet' && (!haiku || score > HAIKU_ESCALATE_ABOVE || tokens > HAIKU_MAX_TOKENS)) return requested;
529
541
  return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
530
542
  }
531
543
 
@@ -547,6 +559,11 @@ async function effortSwapOn($) {
547
559
  return Boolean((await troxyConfig($)).effortSwap);
548
560
  }
549
561
 
562
+ async function haikuBandOn($) {
563
+ if ((await $.env.get('TROXY_HAIKU_BAND')) === '1') return true;
564
+ return Boolean((await troxyConfig($)).haikuBand);
565
+ }
566
+
550
567
  async function* routeStep($, e, next) {
551
568
  // Main loop only: a subagent keeps whatever model it was given.
552
569
  if (e.agentId) {
@@ -575,6 +592,8 @@ async function* routeStep($, e, next) {
575
592
  arm, requested: want, switched,
576
593
  cold: isCold,
577
594
  score: decision ? decision.score : null, warm: ls && ls.model,
595
+ haiku: await haikuBandOn($).catch(() => false),
596
+ tokens: decision ? decision.tokens : 0,
578
597
  });
579
598
  coldNext.delete(sid);
580
599
  lastSent.set(sid, { model, at: now });
@@ -2,7 +2,7 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { _internal } from '../mods/troxy-guard/hooks/register.js';
4
4
 
5
- const { parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS } = _internal;
5
+ const { parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, stepModel, MODELS } = _internal;
6
6
 
7
7
  test('parseScore: reads a bare number and clamps to 0..1', () => {
8
8
  assert.equal(parseScore('0.35'), 0.35);
@@ -90,3 +90,26 @@ 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
+ });
103
+
104
+ test('stepModel: a Haiku session moves up to Sonnet on a hard prompt or a long context, never back down', () => {
105
+ const base = { arm: 'treated', requested: MODELS.sonnet, switched: false, haiku: true };
106
+ assert.equal(stepModel({ ...base, cold: true, score: 0.1, warm: null }), MODELS.haiku);
107
+ assert.equal(stepModel({ ...base, cold: true, score: 0.1, warm: null, tokens: 150000 }), MODELS.sonnet); // too long to start on Haiku
108
+ assert.equal(stepModel({ ...base, cold: false, score: 0.1, warm: MODELS.haiku }), MODELS.haiku); // easy prompt: hold
109
+ assert.equal(stepModel({ ...base, cold: false, score: null, warm: MODELS.haiku }), MODELS.haiku); // no score: hold
110
+ assert.equal(stepModel({ ...base, cold: false, score: 0.7, warm: MODELS.haiku }), MODELS.sonnet); // hard prompt: up
111
+ assert.equal(stepModel({ ...base, cold: false, score: 0.1, warm: MODELS.haiku, tokens: 120000 }), MODELS.sonnet); // long: up
112
+ assert.equal(stepModel({ ...base, cold: false, score: 0.1, warm: MODELS.sonnet }), MODELS.sonnet); // Sonnet never goes down
113
+ assert.equal(stepModel({ ...base, haiku: false, cold: false, score: 0.1, warm: MODELS.haiku }), MODELS.sonnet); // flag off: up
114
+ assert.equal(stepModel({ ...base, arm: 'holdout', cold: true, score: 0.1, warm: null }), MODELS.sonnet); // holdout untouched
115
+ });