troxy-cli 1.30.0 → 1.30.2
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.
|
|
3
|
+
"version": "1.30.2",
|
|
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": {
|
|
@@ -181,13 +181,19 @@ function sessionLine(t) {
|
|
|
181
181
|
Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
//
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
184
|
+
// This session's Troxy work, from the server's count of its calls: how many ran on a
|
|
185
|
+
// lighter model than asked, plus tokens kept out of requests (compression + trimming,
|
|
186
|
+
// API-key setups through the proxy only). A holdout session says so: it is never routed.
|
|
187
|
+
// undefined until there is something to say.
|
|
188
|
+
function savingsText(s, arm) {
|
|
189
|
+
const calls = (s && s.calls) || 0, routed = (s && s.routed_calls) || 0;
|
|
190
|
+
const kept = ((s && s.compressed_tokens) || 0) + ((s && s.trimmed_tokens) || 0);
|
|
188
191
|
const k = n => n >= 1e6 ? `${Math.round(n / 1e5) / 10}M`.replace('.0M', 'M') : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
189
|
-
|
|
190
|
-
|
|
192
|
+
if (arm === 'holdout') return calls ? 'comparison session: not routed, so Troxy can measure the difference' : undefined;
|
|
193
|
+
const parts = [];
|
|
194
|
+
if (calls) parts.push(`${routed} of ${calls} calls on a lighter model`);
|
|
195
|
+
if (kept > 0) parts.push(`${k(kept)} tokens not re-sent`);
|
|
196
|
+
return parts.length ? parts.join(' · ') : undefined;
|
|
191
197
|
}
|
|
192
198
|
|
|
193
199
|
// This session's savings from the server, or null when there is no key, no session id or
|
|
@@ -229,7 +235,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
|
|
|
229
235
|
let pending = [];
|
|
230
236
|
let compacting = false;
|
|
231
237
|
|
|
232
|
-
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits }) {
|
|
238
|
+
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked }) {
|
|
233
239
|
const rl = {};
|
|
234
240
|
for (const w of rateLimits || []) {
|
|
235
241
|
if (!WINDOW_LABEL[w.kind]) continue;
|
|
@@ -241,7 +247,7 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
|
|
|
241
247
|
arm: arm || null, switch: arm ? (cold ? 'cold' : 'warm') : null, score: score == null ? null : score,
|
|
242
248
|
compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
|
|
243
249
|
cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
|
|
244
|
-
rate_limits: rl,
|
|
250
|
+
rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
|
|
245
251
|
};
|
|
246
252
|
}
|
|
247
253
|
|
|
@@ -250,7 +256,7 @@ async function noteStep($, result, meta) {
|
|
|
250
256
|
if (!usage || !usage.model) return;
|
|
251
257
|
let rateLimits = [];
|
|
252
258
|
try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
|
|
253
|
-
pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits }));
|
|
259
|
+
pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits, ts: await $.clock.now() }));
|
|
254
260
|
if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
|
|
255
261
|
}
|
|
256
262
|
|
|
@@ -504,6 +510,24 @@ function stepModel({ arm, requested, switched, cold, score, warm }) {
|
|
|
504
510
|
return w !== null && rq !== null && RANK[w] <= RANK[rq] ? warm : requested;
|
|
505
511
|
}
|
|
506
512
|
|
|
513
|
+
// Effort swap: an easy prompt (scorer) runs at lower effort, so the model thinks and
|
|
514
|
+
// writes less and makes fewer tool calls. Never raised. Claude Code sends the change
|
|
515
|
+
// per message, which keeps the prompt cache (tested 2026-10-20: about 65 cache tokens
|
|
516
|
+
// written on a changed turn, same as an unchanged one). Off unless switched on
|
|
517
|
+
// (config effortSwap, or TROXY_EFFORT_SWAP=1 for an A/B run) until it is measured.
|
|
518
|
+
const EFFORT_RANK = { low: 0, medium: 1, high: 2, xhigh: 3, max: 4 };
|
|
519
|
+
const EFFORT_LOW_BELOW = 0.4;
|
|
520
|
+
const EFFORT_MEDIUM_BELOW = 0.85;
|
|
521
|
+
function stepEffort({ arm, requested, score, on }) {
|
|
522
|
+
if (!on || arm !== 'treated' || score == null || !(requested in EFFORT_RANK)) return requested;
|
|
523
|
+
const cap = score < EFFORT_LOW_BELOW ? 'low' : score < EFFORT_MEDIUM_BELOW ? 'medium' : null;
|
|
524
|
+
return cap && EFFORT_RANK[cap] < EFFORT_RANK[requested] ? cap : requested;
|
|
525
|
+
}
|
|
526
|
+
async function effortSwapOn($) {
|
|
527
|
+
if ((await $.env.get('TROXY_EFFORT_SWAP')) === '1') return true;
|
|
528
|
+
return Boolean((await troxyConfig($)).effortSwap);
|
|
529
|
+
}
|
|
530
|
+
|
|
507
531
|
async function* routeStep($, e, next) {
|
|
508
532
|
// Main loop only: a subagent keeps whatever model it was given.
|
|
509
533
|
if (e.agentId) {
|
|
@@ -546,8 +570,13 @@ async function* routeStep($, e, next) {
|
|
|
546
570
|
}
|
|
547
571
|
try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
|
|
548
572
|
if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
|
|
549
|
-
|
|
550
|
-
|
|
573
|
+
let effort = e.effort;
|
|
574
|
+
if (!(await $.store.get('paused'))) {
|
|
575
|
+
try { effort = stepEffort({ arm, requested: e.effort, score: decision ? decision.score : null, on: await effortSwapOn($) }); } catch {}
|
|
576
|
+
}
|
|
577
|
+
const sent = model === e.model && effort === e.effort ? e : { ...e, model, ...(effort === undefined ? {} : { effort }) };
|
|
578
|
+
const result = yield* next(sent);
|
|
579
|
+
try { await noteStep($, result, { requested: want, arm, cold: isCold, score: decision ? decision.score : null, effort: sent.effort, effortAsked: e.effort }); } catch {} // a missed row only thins the dashboard
|
|
551
580
|
return result;
|
|
552
581
|
}
|
|
553
582
|
|
|
@@ -601,7 +630,7 @@ export function register(on) {
|
|
|
601
630
|
let savings = null;
|
|
602
631
|
try {
|
|
603
632
|
savings = await fetchSavings($);
|
|
604
|
-
if (savings) await $.state.set(SAVINGS, savingsText(savings));
|
|
633
|
+
if (savings) await $.state.set(SAVINGS, savingsText(savings, (await $.state.get(ARM)).value) || null);
|
|
605
634
|
} catch {} // a missed fetch only leaves the band as it was
|
|
606
635
|
if (turn) {
|
|
607
636
|
const { start, ratio } = turn;
|
|
@@ -623,4 +652,4 @@ export function register(on) {
|
|
|
623
652
|
}
|
|
624
653
|
|
|
625
654
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
626
|
-
export const _internal = { 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 };
|
|
655
|
+
export const _internal = { 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 };
|
|
@@ -128,14 +128,14 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
|
|
|
128
128
|
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
|
|
129
129
|
});
|
|
130
130
|
|
|
131
|
-
test('savingsText:
|
|
131
|
+
test('savingsText: calls on a lighter model, kept tokens only when there are some, holdout says so', () => {
|
|
132
132
|
const { savingsText } = _internal;
|
|
133
|
-
assert.equal(savingsText({ compressed_tokens:
|
|
134
|
-
assert.equal(savingsText({ compressed_tokens:
|
|
135
|
-
assert.equal(savingsText({
|
|
136
|
-
assert.equal(savingsText({
|
|
137
|
-
assert.equal(savingsText({
|
|
138
|
-
assert.equal(savingsText(null),
|
|
133
|
+
assert.equal(savingsText({ calls: 5, routed_calls: 5, compressed_tokens: 0, trimmed_tokens: 0 }), '5 of 5 calls on a lighter model');
|
|
134
|
+
assert.equal(savingsText({ calls: 4, routed_calls: 1, compressed_tokens: 5200, trimmed_tokens: 40100 }), '1 of 4 calls on a lighter model · 45k tokens not re-sent');
|
|
135
|
+
assert.equal(savingsText({ calls: 9, routed_calls: 0, compressed_tokens: 123277, trimmed_tokens: 154512861 }), '0 of 9 calls on a lighter model · 154.6M tokens not re-sent');
|
|
136
|
+
assert.equal(savingsText({ calls: 3, routed_calls: 0 }, 'holdout'), 'comparison session: not routed, so Troxy can measure the difference');
|
|
137
|
+
assert.equal(savingsText({ calls: 0 }), undefined);
|
|
138
|
+
assert.equal(savingsText(null), undefined);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
141
|
test('fetchSavings: sends the saved key and the session id; null without a key or on an error', async () => {
|
|
@@ -364,6 +364,7 @@ test('usage report: each request is queued with model, tokens, bar and arm, then
|
|
|
364
364
|
const row = pendingRows().at(-1);
|
|
365
365
|
assert.equal(row.model, M.sonnet); assert.equal(row.model_requested, M.opus);
|
|
366
366
|
assert.equal(row.arm, 'treated'); assert.equal(row.switch, 'cold'); assert.equal(row.cache_read_tokens, 900);
|
|
367
|
+
assert.equal(typeof row.ts, 'number'); // when the request ran, not when the turn ended
|
|
367
368
|
assert.deepEqual(row.rate_limits, { five_hour: { percent_used: 42, resets_at: '2026-10-20T10:00:00Z' } });
|
|
368
369
|
assert.equal(JSON.stringify(row).includes('reply only ok'), false); // never prompt text
|
|
369
370
|
await flushReport($);
|
|
@@ -402,3 +403,32 @@ test('usage report: a server error keeps the rows for the next turn', async () =
|
|
|
402
403
|
await flushReport($);
|
|
403
404
|
assert.equal(pendingRows().length, 0);
|
|
404
405
|
});
|
|
406
|
+
|
|
407
|
+
test('stepEffort: easy prompts drop to low, middle ones to medium, never raised, off unless on', () => {
|
|
408
|
+
const { stepEffort } = _internal;
|
|
409
|
+
const on = { arm: 'treated', on: true };
|
|
410
|
+
assert.equal(stepEffort({ ...on, requested: 'high', score: 0.1 }), 'low');
|
|
411
|
+
assert.equal(stepEffort({ ...on, requested: 'xhigh', score: 0.6 }), 'medium');
|
|
412
|
+
assert.equal(stepEffort({ ...on, requested: 'high', score: 0.9 }), 'high'); // hard: kept
|
|
413
|
+
assert.equal(stepEffort({ ...on, requested: 'low', score: 0.6 }), 'low'); // never raised
|
|
414
|
+
assert.equal(stepEffort({ ...on, requested: 'medium', score: 0.6 }), 'medium');
|
|
415
|
+
assert.equal(stepEffort({ ...on, requested: 'high', score: null }), 'high'); // no score: no change
|
|
416
|
+
assert.equal(stepEffort({ ...on, requested: undefined, score: 0.1 }), undefined); // model without effort
|
|
417
|
+
assert.equal(stepEffort({ ...on, requested: 16000, score: 0.1 }), 16000); // a numeric budget: left alone
|
|
418
|
+
assert.equal(stepEffort({ arm: 'holdout', on: true, requested: 'high', score: 0.1 }), 'high');
|
|
419
|
+
assert.equal(stepEffort({ arm: 'treated', on: false, requested: 'high', score: 0.1 }), 'high');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
test('routeStep: with the effort swap on, an easy prompt goes out at low effort and the row records it', async () => {
|
|
423
|
+
const { MODELS: M, routeStep, scorePrompt, pendingRows } = _internal;
|
|
424
|
+
const $ = { ...routingApi(),
|
|
425
|
+
env: { get: async (k) => (k === 'TROXY_EFFORT_SWAP' ? '1' : undefined) },
|
|
426
|
+
fs: { write: async () => {}, read: async () => { throw new Error('no config'); } } };
|
|
427
|
+
await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
|
|
428
|
+
let sent;
|
|
429
|
+
const gen = routeStep($, { model: M.opus, effort: 'high' }, async function* (e) { sent = e; return { usage: { model: e.model, output_tokens: 1 } }; });
|
|
430
|
+
for await (const _ of gen) { /* drain */ }
|
|
431
|
+
assert.equal(sent.effort, 'low');
|
|
432
|
+
assert.equal(pendingRows().at(-1).effort, 'low');
|
|
433
|
+
assert.equal(pendingRows().at(-1).effort_requested, 'high');
|
|
434
|
+
});
|