troxy-cli 1.30.3 → 1.30.5
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.5",
|
|
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": {
|
|
@@ -161,7 +161,7 @@ function bar(fraction) {
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
// One line, most important first, e.g.
|
|
164
|
-
//
|
|
164
|
+
// 5h: 19% used · 7d: 12% used
|
|
165
165
|
function savedLine(money, rateLimits = []) {
|
|
166
166
|
const paid = money && money.cost_paid_usd;
|
|
167
167
|
if (!(paid > 0)) return undefined;
|
|
@@ -170,8 +170,7 @@ function savedLine(money, rateLimits = []) {
|
|
|
170
170
|
// measured replacement exists.
|
|
171
171
|
const moved = rateLimits.filter(w => WINDOW_LABEL[w.kind] && Math.round(w.percentUsed) >= 1);
|
|
172
172
|
if (!(money.dollars_saved_usd > 0) && !moved.length) return undefined; // nothing routed or cleared yet
|
|
173
|
-
return ['
|
|
174
|
-
...moved.map(w => `${WINDOW_LABEL[w.kind]}: ${Math.round(w.percentUsed)}% used`)].join(' · ');
|
|
173
|
+
return moved.map(w => `${WINDOW_LABEL[w.kind]}: ${Math.round(w.percentUsed)}% used`).join(' · ') || undefined;
|
|
175
174
|
}
|
|
176
175
|
|
|
177
176
|
// t.kept: what the content Troxy cleared or compressed would have cost (server's
|
|
@@ -181,19 +180,15 @@ function sessionLine(t) {
|
|
|
181
180
|
Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
//
|
|
185
|
-
// lighter model
|
|
186
|
-
//
|
|
187
|
-
//
|
|
183
|
+
// What Troxy did in this session, from the server's count of its calls: the share that
|
|
184
|
+
// ran on a lighter model instead of the model asked for. A holdout session says so: it is never
|
|
185
|
+
// routed. undefined until there is something to say. (Tokens cleared by the old proxy are
|
|
186
|
+
// not shown: subscription sessions no longer go through it.)
|
|
188
187
|
function savingsText(s, arm) {
|
|
189
188
|
const calls = (s && s.calls) || 0, routed = (s && s.routed_calls) || 0;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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;
|
|
189
|
+
if (!calls) return undefined;
|
|
190
|
+
if (arm === 'holdout') return 'comparison session: not routed, so Troxy can measure the difference';
|
|
191
|
+
return routed ? `${Math.max(1, Math.round(routed / calls * 100))}% of this session ran on a lighter model` : 'no calls moved to a lighter model yet';
|
|
197
192
|
}
|
|
198
193
|
|
|
199
194
|
// This session's savings from the server, or null when there is no key, no session id or
|
|
@@ -235,7 +230,7 @@ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachab
|
|
|
235
230
|
let pending = [];
|
|
236
231
|
let compacting = false;
|
|
237
232
|
|
|
238
|
-
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces }) {
|
|
233
|
+
function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits, ts, effort, effortAsked, surfaces, compressed }) {
|
|
239
234
|
const rl = {};
|
|
240
235
|
for (const w of rateLimits || []) {
|
|
241
236
|
if (!WINDOW_LABEL[w.kind]) continue;
|
|
@@ -248,10 +243,26 @@ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimit
|
|
|
248
243
|
compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
|
|
249
244
|
cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
|
|
250
245
|
rate_limits: rl, ts, effort: effort || null, effort_requested: effortAsked || null,
|
|
246
|
+
compressed_tokens: compressed > 0 ? compressed : null,
|
|
251
247
|
surface: (surfaces || []).includes('desktop') ? 'desktop' : (surfaces || []).includes('terminal') ? 'terminal' : null,
|
|
252
248
|
};
|
|
253
249
|
}
|
|
254
250
|
|
|
251
|
+
// Tokens the compression hook kept out of this session since the last report (it appends one
|
|
252
|
+
// line per compressed tool output, see sandbox/posttooluse-hook.js). Each count is reported once.
|
|
253
|
+
const savedSeen = new Map();
|
|
254
|
+
async function compressedSince($, sid) {
|
|
255
|
+
try {
|
|
256
|
+
if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sid))) return 0;
|
|
257
|
+
const home = await $.env.get('HOME');
|
|
258
|
+
const text = await $.fs.read(`${home}/.troxy/sandbox/saved/${sid}.log`);
|
|
259
|
+
const total = String(text).split('\n').reduce((n, l) => n + (Number(l) || 0), 0);
|
|
260
|
+
const delta = Math.max(0, total - (savedSeen.get(sid) || 0));
|
|
261
|
+
savedSeen.set(sid, total);
|
|
262
|
+
return delta;
|
|
263
|
+
} catch { return 0; }
|
|
264
|
+
}
|
|
265
|
+
|
|
255
266
|
async function noteStep($, result, meta) {
|
|
256
267
|
const usage = result && result.usage;
|
|
257
268
|
if (!usage || !usage.model) return;
|
|
@@ -259,7 +270,8 @@ async function noteStep($, result, meta) {
|
|
|
259
270
|
try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
|
|
260
271
|
let surfaces = [];
|
|
261
272
|
try { surfaces = await $.session.surfaces(); } catch {}
|
|
262
|
-
|
|
273
|
+
const sid = await $.session.id();
|
|
274
|
+
pending.push(reportRow({ ...meta, sid, usage, compact: compacting, rateLimits, surfaces, compressed: await compressedSince($, sid), ts: await $.clock.now() }));
|
|
263
275
|
if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
|
|
264
276
|
}
|
|
265
277
|
|
|
@@ -278,10 +290,9 @@ async function flushReport($) {
|
|
|
278
290
|
}
|
|
279
291
|
|
|
280
292
|
// /troxy status: everything about this session's Troxy in one place.
|
|
281
|
-
function statusText({ version, paused,
|
|
293
|
+
function statusText({ version, paused, savings, rateLimits = [], compactOff = false }) {
|
|
282
294
|
const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
|
|
283
|
-
lines.push(
|
|
284
|
-
: 'This session: nothing moved to a cheaper model yet');
|
|
295
|
+
lines.push(`This session: ${savings || 'nothing moved to a lighter model yet'}`);
|
|
285
296
|
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
286
297
|
if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
|
|
287
298
|
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.');
|
|
@@ -606,8 +617,7 @@ export function register(on) {
|
|
|
606
617
|
await $.store.set('paused', r.paused);
|
|
607
618
|
if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
|
|
608
619
|
const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
|
|
609
|
-
|
|
610
|
-
return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
|
|
620
|
+
return { text: statusText({ version: manifest.version, paused: r.paused, savings: (await $.state.get(SAVINGS)).value, rateLimits: (await $.session.usage()).rateLimits, compactOff: Boolean(await $.store.get('compactOff')) }) };
|
|
611
621
|
});
|
|
612
622
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
613
623
|
// it just means the requested model is used as-is.
|
|
@@ -624,7 +634,7 @@ export function register(on) {
|
|
|
624
634
|
const { value: kept } = await $.state.get(SAVINGS);
|
|
625
635
|
if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
|
|
626
636
|
const { Text } = $.ui.resolve(e);
|
|
627
|
-
return h(Text, { color: TROXY_TEAL }, `Troxy ${[
|
|
637
|
+
return h(Text, { color: TROXY_TEAL }, `Troxy · ${[kept, line, note && note.text].filter(Boolean).join(' · ')}`);
|
|
628
638
|
});
|
|
629
639
|
// After the answer, so counting never delays a prompt.
|
|
630
640
|
on('turn.complete', async ($, e, next) => {
|
|
@@ -655,4 +665,4 @@ export function register(on) {
|
|
|
655
665
|
}
|
|
656
666
|
|
|
657
667
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
658
|
-
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 };
|
|
668
|
+
export const _internal = { compressedSince, 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 };
|
|
@@ -38,6 +38,18 @@ function sessionArm(dir, sessionId, holdoutFraction, randomFn) {
|
|
|
38
38
|
return arm;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// One line per compression: the tokens it kept out of the conversation (about 4 characters
|
|
42
|
+
// each). The troxy-guard mod reads this per session and reports it, since in the mod-only setup
|
|
43
|
+
// no proxy sees the traffic. Append-only so parallel hook processes never overwrite each other.
|
|
44
|
+
export function recordSaved(dir, sessionId, tokens) {
|
|
45
|
+
if (typeof sessionId !== 'string' || !SESSION_ID_RE.test(sessionId) || !(tokens > 0)) return;
|
|
46
|
+
try {
|
|
47
|
+
const file = path.join(dir, 'saved', `${sessionId}.log`);
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.appendFileSync(file, `${Math.round(tokens)}\n`);
|
|
50
|
+
} catch { /* fails open: a missed count never blocks the tool output */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
41
53
|
function writeState(dir, state) {
|
|
42
54
|
try {
|
|
43
55
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -117,6 +129,7 @@ export async function buildHookResponse(toolUseBlock, {
|
|
|
117
129
|
const { handle } = await put(output, { summary, tags: [] }, storeDir);
|
|
118
130
|
writeState(storeDir, 'treated');
|
|
119
131
|
const updatedText = `${summary}\n\n(troxy_recall("${handle}") to fetch the full output)`;
|
|
132
|
+
recordSaved(storeDir, toolUseBlock?.session_id, (output.length - updatedText.length) / 4);
|
|
120
133
|
return {
|
|
121
134
|
hookSpecificOutput: {
|
|
122
135
|
hookEventName: 'PostToolUse',
|
|
@@ -67,10 +67,10 @@ 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
|
+
'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 }, []),
|
|
72
|
+
'5h: 12% used');
|
|
73
|
+
assert.equal(savedLine({ cost_paid_usd: 1.64, dollars_saved_usd: 4.53 }, []), undefined);
|
|
74
74
|
assert.equal(savedLine(null, meter), undefined);
|
|
75
75
|
assert.doesNotMatch(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 50 }, meter), /longer|instead of/);
|
|
76
76
|
});
|
|
@@ -83,11 +83,11 @@ test('addTurn: a session total from its own turns, a routed one counted at the a
|
|
|
83
83
|
// Not routed; the 5h window reset mid-turn, so its move counts as 0, not negative.
|
|
84
84
|
t = addTurn(t, { cost: 1.5, meter: { five_hour: 13, seven_day: 5 } }, { cost: 2, meter: { five_hour: 1, seven_day: 6 } }, 1);
|
|
85
85
|
assert.deepEqual(t, { paid: 1, full: 1.5, meter: { five_hour: 3, seven_day: 2 } });
|
|
86
|
-
assert.equal(sessionLine(t), '
|
|
86
|
+
assert.equal(sessionLine(t), '5h: 3% used · 7d: 2% used');
|
|
87
87
|
// Cleared/compressed content counts too, and survives the next turn: $1 paid, $0.5 swap + $1.5 kept = 3x.
|
|
88
88
|
t = addTurn({ ...t, kept: 1.5 }, { cost: 2, meter: {} }, { cost: 2, meter: {} }, 1);
|
|
89
89
|
assert.equal(t.kept, 1.5);
|
|
90
|
-
assert.match(sessionLine(t), /
|
|
90
|
+
assert.match(sessionLine(t), /5h: 3% used/);
|
|
91
91
|
// Server total of a session already 28 dollars deep: only growth after the first reading counts.
|
|
92
92
|
const { withKept } = _internal;
|
|
93
93
|
let k = { paid: 1, full: 1, meter: {} };
|
|
@@ -102,16 +102,15 @@ test('addTurn: a session total from its own turns, a routed one counted at the a
|
|
|
102
102
|
|
|
103
103
|
test('statusText: version, routing, this session, plan usage, where the account view is', () => {
|
|
104
104
|
const { statusText } = _internal;
|
|
105
|
-
|
|
106
|
-
assert.equal(statusText({ version: '1.29.42', paused: false, totals, rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
|
|
105
|
+
assert.equal(statusText({ version: '1.29.42', paused: false, savings: '55% of this session ran on a lighter model', rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
|
|
107
106
|
'Troxy 1.29.42 · routing is on\n'
|
|
108
|
-
+ 'This session:
|
|
107
|
+
+ 'This session: 55% of this session ran on a lighter model\n'
|
|
109
108
|
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
110
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'
|
|
111
110
|
+ 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
|
|
112
111
|
+ 'All sessions: dash.troxy.io\n'
|
|
113
112
|
+ 'Type /troxy off to pause routing.');
|
|
114
|
-
assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a
|
|
113
|
+
assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a lighter model yet\n/);
|
|
115
114
|
});
|
|
116
115
|
|
|
117
116
|
test('compactPlan: big idle sessions compact, small ones and a nearly full bar do not', () => {
|
|
@@ -128,11 +127,11 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
|
|
|
128
127
|
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
|
|
129
128
|
});
|
|
130
129
|
|
|
131
|
-
test('savingsText: calls on a lighter model,
|
|
130
|
+
test('savingsText: share of calls on a lighter model, holdout says so, nothing before the first call', () => {
|
|
132
131
|
const { savingsText } = _internal;
|
|
133
|
-
assert.equal(savingsText({ calls: 5, routed_calls: 5
|
|
134
|
-
assert.equal(savingsText({ calls:
|
|
135
|
-
assert.equal(savingsText({ calls: 9, routed_calls: 0
|
|
132
|
+
assert.equal(savingsText({ calls: 5, routed_calls: 5 }), '100% of this session ran on a lighter model');
|
|
133
|
+
assert.equal(savingsText({ calls: 1121, routed_calls: 622, trimmed_tokens: 35100000 }), '55% of this session ran on a lighter model');
|
|
134
|
+
assert.equal(savingsText({ calls: 9, routed_calls: 0 }), 'no calls moved to a lighter model yet');
|
|
136
135
|
assert.equal(savingsText({ calls: 3, routed_calls: 0 }, 'holdout'), 'comparison session: not routed, so Troxy can measure the difference');
|
|
137
136
|
assert.equal(savingsText({ calls: 0 }), undefined);
|
|
138
137
|
assert.equal(savingsText(null), undefined);
|
|
@@ -440,3 +439,24 @@ test('reportRow: surface is desktop when the app draws the session, terminal oth
|
|
|
440
439
|
assert.equal(reportRow({ ...base, surfaces: ['terminal'] }).surface, 'terminal');
|
|
441
440
|
assert.equal(reportRow({ ...base, surfaces: [] }).surface, null);
|
|
442
441
|
});
|
|
442
|
+
|
|
443
|
+
test('reportRow carries the tokens compression kept out, null when none', () => {
|
|
444
|
+
const { reportRow } = _internal;
|
|
445
|
+
const usage = { model: 'claude-sonnet-5-5', input_tokens: 1, output_tokens: 1 };
|
|
446
|
+
assert.equal(reportRow({ sid: 's1', usage, compressed: 4200 }).compressed_tokens, 4200);
|
|
447
|
+
assert.equal(reportRow({ sid: 's1', usage, compressed: 0 }).compressed_tokens, null);
|
|
448
|
+
assert.equal(reportRow({ sid: 's1', usage }).compressed_tokens, null);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('compressedSince reports each compression once, per session', async () => {
|
|
452
|
+
const { compressedSince } = _internal;
|
|
453
|
+
const sid = 'cccccccc-1111-2222-3333-444444444444';
|
|
454
|
+
let log = '1000\n500\n';
|
|
455
|
+
const $ = { env: { get: async () => '/home/u' }, fs: { read: async (p) => { assert.ok(p.endsWith(`/.troxy/sandbox/saved/${sid}.log`)); return log; } } };
|
|
456
|
+
assert.equal(await compressedSince($, sid), 1500);
|
|
457
|
+
assert.equal(await compressedSince($, sid), 0);
|
|
458
|
+
log += '250\n';
|
|
459
|
+
assert.equal(await compressedSince($, sid), 250);
|
|
460
|
+
assert.equal(await compressedSince({ ...$, fs: { read: async () => { throw new Error('no file'); } } }, 'dddddddd-1111-2222'), 0);
|
|
461
|
+
assert.equal(await compressedSince($, '../etc/passwd'), 0);
|
|
462
|
+
});
|
|
@@ -223,3 +223,23 @@ test('session_id with path characters is ignored (no file outside the store)', a
|
|
|
223
223
|
{ holdoutFraction: 0.2, randomFn: () => 0, storeDir: dir });
|
|
224
224
|
assert.equal(fs.existsSync(path.join(dir, 'sessions')), false);
|
|
225
225
|
});
|
|
226
|
+
|
|
227
|
+
test('a compression records the tokens it kept out, per session, one line each', async () => {
|
|
228
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
229
|
+
const sid = 'aaaaaaaa-1111-2222-3333-444444444444';
|
|
230
|
+
const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
|
|
231
|
+
const big = 'x'.repeat(8000);
|
|
232
|
+
await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
|
|
233
|
+
await buildHookResponse({ session_id: sid, tool_response: bashResult(big) }, opts);
|
|
234
|
+
const lines = fs.readFileSync(path.join(dir, 'saved', `${sid}.log`), 'utf8').trim().split('\n').map(Number);
|
|
235
|
+
assert.equal(lines.length, 2);
|
|
236
|
+
assert.ok(lines.every(n => n > 1000 && n <= 2000)); // about 8000 chars / 4, minus the excerpt
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('no session id or a small output records nothing', async () => {
|
|
240
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
|
|
241
|
+
const opts = { sandboxFraction: 1, holdoutFraction: 0, randomFn: () => 0.5, storeDir: dir };
|
|
242
|
+
await buildHookResponse({ tool_response: bashResult('x'.repeat(8000)) }, opts);
|
|
243
|
+
await buildHookResponse({ session_id: 'bbbbbbbb-1111-2222-3333-444444444444', tool_response: bashResult('short') }, opts);
|
|
244
|
+
assert.equal(fs.existsSync(path.join(dir, 'saved')), false);
|
|
245
|
+
});
|