troxy-cli 1.30.22 → 1.30.24
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
|
@@ -213,6 +213,16 @@ async function troxyConfig($) {
|
|
|
213
213
|
return { ...cfg, apiKey: (await $.env.get('TROXY_API_KEY')) || cfg.apiKey };
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
// Settings switches from dash.troxy.io (routing, compact_after_answer, idle_compact), brought
|
|
217
|
+
// by the savings fetch after every answer and kept on disk. A missing one is on. The local
|
|
218
|
+
// /troxy off and /troxy compact off still work on top, for this machine.
|
|
219
|
+
async function switchedOff($, key) {
|
|
220
|
+
try { const f = await $.store.get('features'); return Boolean(f && f[key] === false); } catch { return false; }
|
|
221
|
+
}
|
|
222
|
+
async function routingOff($) {
|
|
223
|
+
return Boolean(await $.store.get('paused')) || switchedOff($, 'routing');
|
|
224
|
+
}
|
|
225
|
+
|
|
216
226
|
async function troxyFetch($, url, init = {}) {
|
|
217
227
|
const controller = new AbortController();
|
|
218
228
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
@@ -226,7 +236,10 @@ async function fetchSavings($) {
|
|
|
226
236
|
const { apiKey } = await troxyConfig($);
|
|
227
237
|
if (!apiKey) return null;
|
|
228
238
|
const res = await troxyFetch($, `https://api.troxy.io/mcp/session-savings?session_id=${sid}`, { headers: { 'x-troxy-key': apiKey } });
|
|
229
|
-
|
|
239
|
+
if (!res.ok) return null;
|
|
240
|
+
const body = JSON.parse(res.text);
|
|
241
|
+
if (body && body.features && typeof body.features === 'object') await $.store.set('features', body.features);
|
|
242
|
+
return body;
|
|
230
243
|
}
|
|
231
244
|
|
|
232
245
|
// Usage reporting. A Claude subscription's login token must never leave the Mac
|
|
@@ -305,7 +318,7 @@ async function flushReport($) {
|
|
|
305
318
|
}
|
|
306
319
|
|
|
307
320
|
// /troxy status: everything about this session's Troxy in one place.
|
|
308
|
-
function statusText({ version, paused, savings, rateLimits = [], compactOff = false }) {
|
|
321
|
+
function statusText({ version, paused, savings, rateLimits = [], compactOff = false, settingsOff = [] }) {
|
|
309
322
|
const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
|
|
310
323
|
lines.push(`This session: ${savings || 'nothing moved to a lighter model yet'}`);
|
|
311
324
|
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
@@ -313,7 +326,8 @@ function statusText({ version, paused, savings, rateLimits = [], compactOff = fa
|
|
|
313
326
|
lines.push('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.');
|
|
314
327
|
lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
|
|
315
328
|
: `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
|
|
316
|
-
lines.push(
|
|
329
|
+
if (settingsOff.length) lines.push(`Turned off in Settings on dash.troxy.io: ${settingsOff.join(', ')}.`);
|
|
330
|
+
lines.push('All sessions and the on/off switches: dash.troxy.io');
|
|
317
331
|
lines.push(paused ? 'Type /troxy on to resume.' : 'Type /troxy off to pause routing.');
|
|
318
332
|
return lines.join('\n');
|
|
319
333
|
}
|
|
@@ -360,6 +374,24 @@ const COMPACTED = { plugin: 'troxy-guard', key: 'compacted' }; // the band's not
|
|
|
360
374
|
const COMPACTED_SHOW_MS = 10 * 60 * 1000; // kept through prompts for this long, so it isn't missed
|
|
361
375
|
let compactTimer = null;
|
|
362
376
|
|
|
377
|
+
// Big-session compaction: a session past 200k compacts right after an answer, between
|
|
378
|
+
// tasks, while its cache is still warm, instead of re-reading the whole thing on every
|
|
379
|
+
// later call. A/B 2026-11-06 at 350k on Sonnet: 4.0 vs 6.0 bar points for the same 9-step
|
|
380
|
+
// job, the compaction included, same results. The short delay lets the turn settle; a
|
|
381
|
+
// prompt typed first cancels it (scorePrompt), so it never holds the user up.
|
|
382
|
+
// On for everyone since 1.30.24 (Settings: compact_after_answer). TROXY_BIG_COMPACT=1 or 0
|
|
383
|
+
// pins it for an A/B run.
|
|
384
|
+
const BIG_COMPACT_TOKENS = 200000;
|
|
385
|
+
const BIG_COMPACT_DELAY_MS = 3000;
|
|
386
|
+
function afterTurnDelay({ tokens, on, off }) {
|
|
387
|
+
return on && !off && tokens >= BIG_COMPACT_TOKENS ? BIG_COMPACT_DELAY_MS : COMPACT_IDLE_MS;
|
|
388
|
+
}
|
|
389
|
+
async function bigCompactOn($) {
|
|
390
|
+
const pin = await $.env.get('TROXY_BIG_COMPACT');
|
|
391
|
+
if (pin === '1' || pin === '0') return pin === '1';
|
|
392
|
+
return !(await switchedOff($, 'compact_after_answer'));
|
|
393
|
+
}
|
|
394
|
+
|
|
363
395
|
// What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
|
|
364
396
|
function compactPlan({ tokens, rateLimits = [], now, off }) {
|
|
365
397
|
if (off || !(tokens >= COMPACT_MIN_TOKENS)) return { action: 'skip' };
|
|
@@ -381,7 +413,7 @@ function cancelCompact() {
|
|
|
381
413
|
// stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
|
|
382
414
|
// big session compacts on time instead of when the user is back. macOS caffeinate; where
|
|
383
415
|
// it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
|
|
384
|
-
function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
|
|
416
|
+
function armCompact($, ms, { stayAwake = false, persist = false, afterAnswer = false } = {}) {
|
|
385
417
|
cancelCompact();
|
|
386
418
|
if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
|
|
387
419
|
// Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
|
|
@@ -391,7 +423,7 @@ function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
|
|
|
391
423
|
} catch {}
|
|
392
424
|
compactTimer = $.clock.after(ms, () => {
|
|
393
425
|
compactTimer = null;
|
|
394
|
-
idleCompact(
|
|
426
|
+
idleCompact($, { afterAnswer }).catch(() => {}).finally(stopAwake);
|
|
395
427
|
});
|
|
396
428
|
}
|
|
397
429
|
|
|
@@ -412,17 +444,18 @@ function stopAwake() {
|
|
|
412
444
|
}
|
|
413
445
|
|
|
414
446
|
async function bigSession($) {
|
|
415
|
-
if (await $.store.get('compactOff')) return false;
|
|
447
|
+
if ((await $.store.get('compactOff')) || (await switchedOff($, 'idle_compact'))) return false;
|
|
416
448
|
const u = await $.session.usage();
|
|
417
449
|
return ((u.context && u.context.tokens) || 0) >= COMPACT_MIN_TOKENS;
|
|
418
450
|
}
|
|
419
451
|
|
|
420
452
|
// late: the user is back after a long idle (the Mac may have slept through the timer),
|
|
421
453
|
// so compact now or not at all: never hold their prompt waiting for a meter reset.
|
|
422
|
-
async function idleCompact($, { late = false } = {}) {
|
|
454
|
+
async function idleCompact($, { late = false, afterAnswer = false } = {}) {
|
|
423
455
|
const u = await $.session.usage();
|
|
424
|
-
const
|
|
425
|
-
|
|
456
|
+
const off = Boolean(await $.store.get('compactOff')) || await switchedOff($, afterAnswer ? 'compact_after_answer' : 'idle_compact');
|
|
457
|
+
const plan = compactPlan({ tokens: u.context && u.context.tokens, rateLimits: u.rateLimits, now: await $.clock.now(), off });
|
|
458
|
+
if (plan.action === 'wait' && !late) return armCompact($, plan.inMs, { afterAnswer });
|
|
426
459
|
if (plan.action !== 'compact') return;
|
|
427
460
|
// Tags the summary request so the proxy logs it as Troxy's compaction (the Activity row).
|
|
428
461
|
const file = modRouteFile($.plugin.root, await $.session.id());
|
|
@@ -434,7 +467,7 @@ async function idleCompact($, { late = false } = {}) {
|
|
|
434
467
|
if (r.skip) return;
|
|
435
468
|
const k = n => `${Math.round(n / 1000)}k`;
|
|
436
469
|
await $.state.set(COMPACTED, { at: await $.clock.now(), text: r.tokensBefore && r.tokensAfter
|
|
437
|
-
? `compacted this
|
|
470
|
+
? `compacted this session ${k(r.tokensBefore)} → ${k(r.tokensAfter)} tokens` : 'compacted this session' });
|
|
438
471
|
}
|
|
439
472
|
|
|
440
473
|
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
@@ -474,7 +507,7 @@ async function scorePrompt($, e, next) {
|
|
|
474
507
|
turn = { start: await snapshot($), ratio: 1 };
|
|
475
508
|
const file = modRouteFile($.plugin.root, sid);
|
|
476
509
|
if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
|
|
477
|
-
if (await
|
|
510
|
+
if (await routingOff($)) return next(e);
|
|
478
511
|
const kind = e.origin && e.origin.kind;
|
|
479
512
|
if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
|
|
480
513
|
const r = await $.model.complete(
|
|
@@ -588,7 +621,7 @@ async function* routeStep($, e, next) {
|
|
|
588
621
|
const want = await askedFor($, sid, e.model, switched);
|
|
589
622
|
const ls = await lastSentFor($, sid);
|
|
590
623
|
const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
|
|
591
|
-
const model = (await
|
|
624
|
+
const model = (await routingOff($)) ? want : stepModel({
|
|
592
625
|
arm, requested: want, switched,
|
|
593
626
|
cold: isCold,
|
|
594
627
|
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
@@ -609,7 +642,7 @@ async function* routeStep($, e, next) {
|
|
|
609
642
|
try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
|
|
610
643
|
if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
|
|
611
644
|
let effort = e.effort;
|
|
612
|
-
if (!(await
|
|
645
|
+
if (!(await routingOff($))) {
|
|
613
646
|
try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
|
|
614
647
|
}
|
|
615
648
|
const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
|
|
@@ -641,7 +674,12 @@ export function register(on) {
|
|
|
641
674
|
await $.store.set('paused', r.paused);
|
|
642
675
|
if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
|
|
643
676
|
const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
|
|
644
|
-
|
|
677
|
+
const settingsOff = [];
|
|
678
|
+
for (const [k, label] of [['routing', 'model routing'], ['compact_after_answer', 'compacting after an answer past 200k'], ['idle_compact', 'idle compaction']]) {
|
|
679
|
+
if (await switchedOff($, k)) settingsOff.push(label);
|
|
680
|
+
}
|
|
681
|
+
return { text: statusText({ version: manifest.version, paused: r.paused || await switchedOff($, 'routing'), savings: (await $.state.get(SAVINGS)).value, rateLimits: (await $.session.usage()).rateLimits,
|
|
682
|
+
compactOff: Boolean(await $.store.get('compactOff')) || await switchedOff($, 'idle_compact'), settingsOff }) };
|
|
645
683
|
});
|
|
646
684
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
647
685
|
// it just means the requested model is used as-is.
|
|
@@ -681,12 +719,16 @@ export function register(on) {
|
|
|
681
719
|
if (line) await $.state.set(LINE, line);
|
|
682
720
|
} catch {} // a missed turn only leaves the band as it was
|
|
683
721
|
}
|
|
684
|
-
let stayAwake = false;
|
|
722
|
+
let stayAwake = false, delay = COMPACT_IDLE_MS;
|
|
685
723
|
try { stayAwake = await bigSession($); } catch {}
|
|
686
|
-
|
|
724
|
+
try {
|
|
725
|
+
delay = afterTurnDelay({ tokens: ((await $.session.usage()).context || {}).tokens || 0,
|
|
726
|
+
on: await bigCompactOn($), off: await $.store.get('compactOff') });
|
|
727
|
+
} catch {}
|
|
728
|
+
armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true, afterAnswer: delay !== COMPACT_IDLE_MS });
|
|
687
729
|
return r;
|
|
688
730
|
});
|
|
689
731
|
}
|
|
690
732
|
|
|
691
733
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
692
|
-
export const _internal = { compressedSince, recalledSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, 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 };
|
|
734
|
+
export const _internal = { compressedSince, recalledSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, afterTurnDelay, bigCompactOn, idleCompact, switchedOff, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
|
|
@@ -108,9 +108,50 @@ test('statusText: version, routing, this session, plan usage, where the account
|
|
|
108
108
|
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
109
109
|
+ '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'
|
|
110
110
|
+ 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
|
|
111
|
-
+ 'All sessions: dash.troxy.io\n'
|
|
111
|
+
+ 'All sessions and the on/off switches: dash.troxy.io\n'
|
|
112
112
|
+ 'Type /troxy off to pause routing.');
|
|
113
113
|
assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a lighter model yet\n/);
|
|
114
|
+
assert.match(statusText({ settingsOff: ['model routing'] }), /\nTurned off in Settings on dash\.troxy\.io: model routing\.\n/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('Settings switches: kept from the savings fetch, on unless set false, env pins compact-after-answer', async () => {
|
|
118
|
+
const { fetchSavings, bigCompactOn, switchedOff, idleCompact } = _internal;
|
|
119
|
+
const disk = new Map();
|
|
120
|
+
const env = { HOME: '/h' };
|
|
121
|
+
const compacted = [];
|
|
122
|
+
const $ = {
|
|
123
|
+
session: { id: async () => 'abc-12345678', usage: async () => ({ context: { tokens: 300000 }, rateLimits: [] }),
|
|
124
|
+
compact: async () => { compacted.push(1); return { skip: true }; } },
|
|
125
|
+
env: { get: async n => env[n] },
|
|
126
|
+
fs: { read: async () => JSON.stringify({ apiKey: 'txy-1' }), write: async () => {} },
|
|
127
|
+
http: { fetch: async () => ({ ok: true, text: JSON.stringify({ calls: 1, features: { compact_after_answer: false } }) }) },
|
|
128
|
+
store: { get: async k => disk.get(k), set: async (k, v) => disk.set(k, v) },
|
|
129
|
+
clock: { now: async () => 0 },
|
|
130
|
+
plugin: { root: '/tmp/x' },
|
|
131
|
+
state: { set: async () => {} },
|
|
132
|
+
};
|
|
133
|
+
assert.equal(await bigCompactOn($), true); // nothing from Settings yet: on for everyone
|
|
134
|
+
await fetchSavings($);
|
|
135
|
+
assert.deepEqual(disk.get('features'), { compact_after_answer: false });
|
|
136
|
+
assert.equal(await bigCompactOn($), false);
|
|
137
|
+
assert.equal(await switchedOff($, 'routing'), false); // missing = on
|
|
138
|
+
await idleCompact($, { afterAnswer: true });
|
|
139
|
+
assert.equal(compacted.length, 0); // the after-answer switch is off
|
|
140
|
+
await idleCompact($);
|
|
141
|
+
assert.equal(compacted.length, 1); // idle compaction is still on
|
|
142
|
+
env.TROXY_BIG_COMPACT = '1';
|
|
143
|
+
assert.equal(await bigCompactOn($), true);
|
|
144
|
+
env.TROXY_BIG_COMPACT = '0'; disk.clear();
|
|
145
|
+
assert.equal(await bigCompactOn($), false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('afterTurnDelay: with bigCompact on, a session past 200k compacts right after the answer', () => {
|
|
149
|
+
const { afterTurnDelay } = _internal;
|
|
150
|
+
const IDLE = 55 * 60 * 1000;
|
|
151
|
+
assert.equal(afterTurnDelay({ tokens: 351000, on: true }), 3000);
|
|
152
|
+
assert.equal(afterTurnDelay({ tokens: 199000, on: true }), IDLE); // under 200k: idle path only
|
|
153
|
+
assert.equal(afterTurnDelay({ tokens: 351000, on: false }), IDLE); // flag off
|
|
154
|
+
assert.equal(afterTurnDelay({ tokens: 351000, on: true, off: true }), IDLE); // /troxy compact off wins
|
|
114
155
|
});
|
|
115
156
|
|
|
116
157
|
test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {
|