troxy-cli 1.29.61 → 1.29.63
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.
|
|
3
|
+
"version": "1.29.63",
|
|
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
|
-
|
|
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;
|
|
@@ -156,25 +158,17 @@ function bar(fraction) {
|
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
// One line, most important first, e.g.
|
|
159
|
-
//
|
|
161
|
+
// This session: savings under measurement · 5h: 19% used · 7d: 12% used
|
|
160
162
|
function savedLine(money, rateLimits = []) {
|
|
161
|
-
const paid = money && money.cost_paid_usd
|
|
163
|
+
const paid = money && money.cost_paid_usd;
|
|
162
164
|
if (!(paid > 0)) return undefined;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(' · ')}`;
|
|
165
|
+
// No "N times longer" claim: the 2026-10-15/16 fixed-job A/Bs (Troxy on vs off, bar read
|
|
166
|
+
// from Claude Code) found routing used MORE bar, so the estimate is not shown until a
|
|
167
|
+
// measured replacement exists.
|
|
168
|
+
const moved = rateLimits.filter(w => WINDOW_LABEL[w.kind] && Math.round(w.percentUsed) >= 1);
|
|
169
|
+
if (!(money.dollars_saved_usd > 0) && !moved.length) return undefined; // nothing routed or cleared yet
|
|
170
|
+
return ['This session: savings under measurement',
|
|
171
|
+
...moved.map(w => `${WINDOW_LABEL[w.kind]}: ${Math.round(w.percentUsed)}% used`)].join(' · ');
|
|
178
172
|
}
|
|
179
173
|
|
|
180
174
|
// t.kept: what the content Troxy cleared or compressed would have cost (server's
|
|
@@ -429,29 +423,13 @@ async function rememberAsked($, sid, want, ran) {
|
|
|
429
423
|
await $.store.set(`asked:${sid}`, { asked: want, ran });
|
|
430
424
|
}
|
|
431
425
|
|
|
432
|
-
// Warm switches: a new model re-writes the cache once, then every request on it is
|
|
433
|
-
// cheaper, so a switch pays back in about one or two typed prompts if the model then
|
|
434
|
-
// holds. Switch at most once per HOLD_PROMPTS prompts, and only while the re-write is
|
|
435
|
-
// small enough to pay back.
|
|
436
|
-
const HOLD_PROMPTS = 2;
|
|
437
|
-
const WARM_SWITCH_MAX_TOKENS = 200000;
|
|
438
|
-
// Per session: the last prompt's decision and how many prompts ran on the current model.
|
|
439
|
-
const held = new Map();
|
|
440
|
-
|
|
441
426
|
// Which model a step runs on. At a cold moment (first step, 55+ min idle, after a
|
|
442
|
-
// compaction)
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
function stepModel({ arm, requested, switched, cold, score, warm
|
|
427
|
+
// compaction) the cache is gone anyway, so pick by score. Otherwise hold the model the
|
|
428
|
+
// cache is warm on: every switch re-writes the whole conversation (2026-10-16 A/B: 4-6
|
|
429
|
+
// switches per session cost more bar than routing saved). /model always wins.
|
|
430
|
+
function stepModel({ arm, requested, switched, cold, score, warm }) {
|
|
446
431
|
if (arm !== 'treated' || switched) return requested;
|
|
447
432
|
if (cold) return pickModel(score, requested, 0, null);
|
|
448
|
-
if (newPrompt && score !== null && tokens <= WARM_SWITCH_MAX_TOKENS) {
|
|
449
|
-
const pick = pickModel(score, requested, 0, null);
|
|
450
|
-
// Up is immediate: a cheap model chosen for an easy first prompt must not keep
|
|
451
|
-
// running a harder one (the 2026-10-15 A/B: Haiku held a whole coding session and
|
|
452
|
-
// made 2-4x the calls). Down still waits HOLD_PROMPTS, since it re-writes the cache.
|
|
453
|
-
if (RANK[tierOf(pick)] > RANK[tierOf(warm)] || heldPrompts >= HOLD_PROMPTS) return pick;
|
|
454
|
-
}
|
|
455
433
|
const w = tierOf(warm), rq = tierOf(requested);
|
|
456
434
|
return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
|
|
457
435
|
}
|
|
@@ -468,20 +446,12 @@ async function* routeStep($, e, next) {
|
|
|
468
446
|
const switched = Boolean(prev && prev !== e.model);
|
|
469
447
|
const want = await askedFor($, sid, e.model, switched);
|
|
470
448
|
const ls = lastSent.get(sid);
|
|
471
|
-
const h = held.get(sid) || { decision: null, prompts: 0 };
|
|
472
|
-
const newPrompt = Boolean(decision) && decision !== h.decision;
|
|
473
|
-
if (newPrompt) h.decision = decision;
|
|
474
449
|
const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
|
|
475
450
|
const model = (await $.store.get('paused')) ? want : stepModel({
|
|
476
451
|
arm, requested: want, switched,
|
|
477
452
|
cold: isCold,
|
|
478
453
|
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
479
|
-
newPrompt, heldPrompts: h.prompts, tokens: decision ? decision.tokens : Infinity,
|
|
480
454
|
});
|
|
481
|
-
// Count prompts on the current model; a change starts the count over.
|
|
482
|
-
if (!ls || model !== ls.model) h.prompts = newPrompt ? 1 : 0;
|
|
483
|
-
else if (newPrompt) h.prompts++;
|
|
484
|
-
held.set(sid, h);
|
|
485
455
|
coldNext.delete(sid);
|
|
486
456
|
lastSent.set(sid, { model, at: now });
|
|
487
457
|
// The daemon tags every call with the arm, plus from/to when the model changed.
|
|
@@ -568,4 +538,4 @@ export function register(on) {
|
|
|
568
538
|
}
|
|
569
539
|
|
|
570
540
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
571
|
-
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
|
|
541
|
+
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 };
|
|
@@ -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.
|
|
32
|
-
assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.
|
|
33
|
-
assert.equal(pickModel(0.2, MODELS.opus, 1000), MODELS.
|
|
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);
|
|
@@ -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:
|
|
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
|
-
'
|
|
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
|
-
'
|
|
73
|
-
assert.equal(savedLine({ cost_paid_usd: 1.64, dollars_saved_usd: 4.53 }, [
|
|
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), '
|
|
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), /
|
|
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:
|
|
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'
|
|
@@ -187,7 +183,7 @@ async function runTurn($, model) {
|
|
|
187
183
|
test('routeStep: a /model switch wins and becomes the held model', async () => {
|
|
188
184
|
const $ = routingApi();
|
|
189
185
|
const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
|
|
190
|
-
assert.equal(await runTurn($, opus),
|
|
186
|
+
assert.equal(await runTurn($, opus), sonnet); // first turn is cold: routed down (never to haiku)
|
|
191
187
|
assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
|
|
192
188
|
assert.equal(await runTurn($, sonnet), sonnet); // warm on sonnet: held, no switch
|
|
193
189
|
});
|
|
@@ -203,46 +199,32 @@ test('stepModel: decide only at cold moments, hold in between, holdout never rou
|
|
|
203
199
|
assert.equal(stepModel({ ...base, switched: true, cold: false, score: 0.1 }), M.opus);
|
|
204
200
|
});
|
|
205
201
|
|
|
206
|
-
test('stepModel: warm
|
|
207
|
-
const { stepModel, MODELS: M
|
|
208
|
-
const base = { arm: 'treated', requested: M.opus, switched: false,
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
assert.equal(stepModel({ ...base,
|
|
214
|
-
assert.equal(stepModel({ ...base, warm: M.haiku, score: 0.9, newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus); // hard prompt: back up
|
|
215
|
-
assert.equal(stepModel({ ...base, arm: 'holdout', newPrompt: true, heldPrompts: HOLD_PROMPTS }), M.opus);
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
test('stepModel: a harder prompt upgrades a cheap warm session at once, a lighter one waits', () => {
|
|
219
|
-
const { stepModel, MODELS: M, HOLD_PROMPTS } = _internal;
|
|
220
|
-
const base = { arm: 'treated', requested: M.opus, switched: false, cold: false, warm: M.haiku, newPrompt: true, tokens: 5000 };
|
|
221
|
-
assert.equal(stepModel({ ...base, score: 0.9, heldPrompts: 0 }), M.opus); // hard prompt on haiku: up now
|
|
222
|
-
assert.equal(stepModel({ ...base, score: 0.5, heldPrompts: 0 }), M.sonnet);
|
|
223
|
-
assert.equal(stepModel({ ...base, score: 0.1, heldPrompts: 0 }), M.haiku); // still easy: stay
|
|
224
|
-
assert.equal(stepModel({ ...base, warm: M.opus, score: 0.1, heldPrompts: 0 }), M.opus); // down waits
|
|
225
|
-
assert.equal(stepModel({ ...base, warm: M.opus, score: 0.1, heldPrompts: HOLD_PROMPTS }), M.haiku); // ...for the hold
|
|
226
|
-
assert.equal(stepModel({ ...base, score: 0.9, heldPrompts: 0, tokens: 300000 }), M.haiku); // too big to re-write
|
|
227
|
-
assert.equal(stepModel({ ...base, score: 0.9, heldPrompts: 0, newPrompt: false }), M.haiku); // mid-turn: hold
|
|
202
|
+
test('stepModel: while warm the model never changes, whatever the next prompt scores', () => {
|
|
203
|
+
const { stepModel, MODELS: M } = _internal;
|
|
204
|
+
const base = { arm: 'treated', requested: M.opus, switched: false, cold: false };
|
|
205
|
+
for (const score of [0.05, 0.5, 0.95, null]) {
|
|
206
|
+
assert.equal(stepModel({ ...base, warm: M.sonnet, score }), M.sonnet);
|
|
207
|
+
assert.equal(stepModel({ ...base, warm: M.opus, score }), M.opus);
|
|
208
|
+
}
|
|
209
|
+
assert.equal(stepModel({ ...base, cold: true, score: 0.05 }), M.sonnet); // cold: re-pick, never haiku
|
|
228
210
|
});
|
|
229
211
|
|
|
230
212
|
test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
|
|
231
213
|
const { pickModel, MODELS } = _internal;
|
|
232
214
|
const big = 80000;
|
|
233
|
-
assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.haiku), MODELS.
|
|
215
|
+
assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.haiku), MODELS.sonnet); // easy: sonnet is now the lowest tier
|
|
234
216
|
assert.equal(pickModel(0.5, MODELS.opus, big, MODELS.haiku), MODELS.sonnet); // needs sonnet: step up
|
|
235
217
|
assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.opus), MODELS.opus); // warm on opus: no switch down
|
|
236
218
|
assert.equal(pickModel(0.1, MODELS.opus, big, null), MODELS.opus); // cold or unknown: as before
|
|
237
|
-
assert.equal(pickModel(0.1, MODELS.opus, 1000, MODELS.opus), MODELS.
|
|
219
|
+
assert.equal(pickModel(0.1, MODELS.opus, 1000, MODELS.opus), MODELS.sonnet); // under the floor: route freely
|
|
238
220
|
});
|
|
239
221
|
|
|
240
|
-
test('routeStep: the session that crossed 50k on
|
|
222
|
+
test('routeStep: the session that crossed 50k on sonnet stays on sonnet (no cache rewrite)', async () => {
|
|
241
223
|
const $ = routingApi();
|
|
242
|
-
const { opus,
|
|
243
|
-
assert.equal(await runTurn($, opus),
|
|
224
|
+
const { opus, sonnet } = _internal.MODELS;
|
|
225
|
+
assert.equal(await runTurn($, opus), sonnet);
|
|
244
226
|
$.session.usage = async () => ({ context: { tokens: 60000 }, rateLimits: [] });
|
|
245
|
-
assert.equal(await runTurn($, opus),
|
|
227
|
+
assert.equal(await runTurn($, opus), sonnet);
|
|
246
228
|
});
|
|
247
229
|
|
|
248
230
|
test('pickModel: no score never causes a switch away from the warm model past the floor', () => {
|