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.
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
function stepModel({ arm, requested, switched, cold, score, warm
|
|
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 =
|
|
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,
|
|
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.
|
|
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);
|
|
@@ -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),
|
|
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
|
|
203
|
-
const { stepModel, MODELS: M
|
|
204
|
-
const base = { arm: 'treated', requested: M.opus, switched: false,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
assert.equal(stepModel({ ...base,
|
|
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.
|
|
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.
|
|
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
|
|
238
|
+
test('routeStep: the session that crossed 50k on sonnet stays on sonnet (no cache rewrite)', async () => {
|
|
237
239
|
const $ = routingApi();
|
|
238
|
-
const { opus,
|
|
239
|
-
assert.equal(await runTurn($, opus),
|
|
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),
|
|
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', () => {
|