troxy-cli 1.30.23 → 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
|
}
|
|
@@ -365,15 +379,17 @@ let compactTimer = null;
|
|
|
365
379
|
// later call. A/B 2026-11-06 at 350k on Sonnet: 4.0 vs 6.0 bar points for the same 9-step
|
|
366
380
|
// job, the compaction included, same results. The short delay lets the turn settle; a
|
|
367
381
|
// prompt typed first cancels it (scorePrompt), so it never holds the user up.
|
|
368
|
-
//
|
|
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.
|
|
369
384
|
const BIG_COMPACT_TOKENS = 200000;
|
|
370
385
|
const BIG_COMPACT_DELAY_MS = 3000;
|
|
371
386
|
function afterTurnDelay({ tokens, on, off }) {
|
|
372
387
|
return on && !off && tokens >= BIG_COMPACT_TOKENS ? BIG_COMPACT_DELAY_MS : COMPACT_IDLE_MS;
|
|
373
388
|
}
|
|
374
389
|
async function bigCompactOn($) {
|
|
375
|
-
|
|
376
|
-
|
|
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'));
|
|
377
393
|
}
|
|
378
394
|
|
|
379
395
|
// What to do when the idle timer fires: skip, compact now, or wait for the meter to reset.
|
|
@@ -397,7 +413,7 @@ function cancelCompact() {
|
|
|
397
413
|
// stayAwake: hold off system sleep (the screen still sleeps) until the timer fires, so a
|
|
398
414
|
// big session compacts on time instead of when the user is back. macOS caffeinate; where
|
|
399
415
|
// it is missing the spawn fails quietly and compact-on-return in scorePrompt covers it.
|
|
400
|
-
function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
|
|
416
|
+
function armCompact($, ms, { stayAwake = false, persist = false, afterAnswer = false } = {}) {
|
|
401
417
|
cancelCompact();
|
|
402
418
|
if (stayAwake) keepAwake($, ms + 2 * 60 * 1000);
|
|
403
419
|
// Persisted so a restart (which loses lastSent and the timer) still finds the idle start.
|
|
@@ -407,7 +423,7 @@ function armCompact($, ms, { stayAwake = false, persist = false } = {}) {
|
|
|
407
423
|
} catch {}
|
|
408
424
|
compactTimer = $.clock.after(ms, () => {
|
|
409
425
|
compactTimer = null;
|
|
410
|
-
idleCompact(
|
|
426
|
+
idleCompact($, { afterAnswer }).catch(() => {}).finally(stopAwake);
|
|
411
427
|
});
|
|
412
428
|
}
|
|
413
429
|
|
|
@@ -428,17 +444,18 @@ function stopAwake() {
|
|
|
428
444
|
}
|
|
429
445
|
|
|
430
446
|
async function bigSession($) {
|
|
431
|
-
if (await $.store.get('compactOff')) return false;
|
|
447
|
+
if ((await $.store.get('compactOff')) || (await switchedOff($, 'idle_compact'))) return false;
|
|
432
448
|
const u = await $.session.usage();
|
|
433
449
|
return ((u.context && u.context.tokens) || 0) >= COMPACT_MIN_TOKENS;
|
|
434
450
|
}
|
|
435
451
|
|
|
436
452
|
// late: the user is back after a long idle (the Mac may have slept through the timer),
|
|
437
453
|
// so compact now or not at all: never hold their prompt waiting for a meter reset.
|
|
438
|
-
async function idleCompact($, { late = false } = {}) {
|
|
454
|
+
async function idleCompact($, { late = false, afterAnswer = false } = {}) {
|
|
439
455
|
const u = await $.session.usage();
|
|
440
|
-
const
|
|
441
|
-
|
|
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 });
|
|
442
459
|
if (plan.action !== 'compact') return;
|
|
443
460
|
// Tags the summary request so the proxy logs it as Troxy's compaction (the Activity row).
|
|
444
461
|
const file = modRouteFile($.plugin.root, await $.session.id());
|
|
@@ -490,7 +507,7 @@ async function scorePrompt($, e, next) {
|
|
|
490
507
|
turn = { start: await snapshot($), ratio: 1 };
|
|
491
508
|
const file = modRouteFile($.plugin.root, sid);
|
|
492
509
|
if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
|
|
493
|
-
if (await
|
|
510
|
+
if (await routingOff($)) return next(e);
|
|
494
511
|
const kind = e.origin && e.origin.kind;
|
|
495
512
|
if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
|
|
496
513
|
const r = await $.model.complete(
|
|
@@ -604,7 +621,7 @@ async function* routeStep($, e, next) {
|
|
|
604
621
|
const want = await askedFor($, sid, e.model, switched);
|
|
605
622
|
const ls = await lastSentFor($, sid);
|
|
606
623
|
const isCold = !ls || now - ls.at >= WARM_MS || coldNext.has(sid);
|
|
607
|
-
const model = (await
|
|
624
|
+
const model = (await routingOff($)) ? want : stepModel({
|
|
608
625
|
arm, requested: want, switched,
|
|
609
626
|
cold: isCold,
|
|
610
627
|
score: decision ? decision.score : null, warm: ls && ls.model,
|
|
@@ -625,7 +642,7 @@ async function* routeStep($, e, next) {
|
|
|
625
642
|
try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
|
|
626
643
|
if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
|
|
627
644
|
let effort = e.effort;
|
|
628
|
-
if (!(await
|
|
645
|
+
if (!(await routingOff($))) {
|
|
629
646
|
try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
|
|
630
647
|
}
|
|
631
648
|
const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
|
|
@@ -657,7 +674,12 @@ export function register(on) {
|
|
|
657
674
|
await $.store.set('paused', r.paused);
|
|
658
675
|
if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
|
|
659
676
|
const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
|
|
660
|
-
|
|
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 }) };
|
|
661
683
|
});
|
|
662
684
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
663
685
|
// it just means the requested model is used as-is.
|
|
@@ -703,10 +725,10 @@ export function register(on) {
|
|
|
703
725
|
delay = afterTurnDelay({ tokens: ((await $.session.usage()).context || {}).tokens || 0,
|
|
704
726
|
on: await bigCompactOn($), off: await $.store.get('compactOff') });
|
|
705
727
|
} catch {}
|
|
706
|
-
armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true });
|
|
728
|
+
armCompact($, delay, { stayAwake: stayAwake && delay === COMPACT_IDLE_MS, persist: true, afterAnswer: delay !== COMPACT_IDLE_MS });
|
|
707
729
|
return r;
|
|
708
730
|
});
|
|
709
731
|
}
|
|
710
732
|
|
|
711
733
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
712
|
-
export const _internal = { compressedSince, recalledSince, stepEffort, reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, afterTurnDelay, 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,41 @@ 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);
|
|
114
146
|
});
|
|
115
147
|
|
|
116
148
|
test('afterTurnDelay: with bigCompact on, a session past 200k compacts right after the answer', () => {
|