troxy-cli 1.29.40 → 1.29.42
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.42",
|
|
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": {
|
package/src/init.js
CHANGED
|
@@ -31,6 +31,10 @@ export async function installTroxyGuardMod(home = os.homedir(), detectFn = detec
|
|
|
31
31
|
const destDir = path.join(home, '.claude', 'skills', 'troxy-guard');
|
|
32
32
|
fs.rmSync(destDir, { recursive: true, force: true });
|
|
33
33
|
fs.cpSync(TROXY_GUARD_MOD_SOURCE_DIR, destDir, { recursive: true });
|
|
34
|
+
// The mod can't see the CLI's version on its own; /troxy status reads it from here.
|
|
35
|
+
const manifest = path.join(destDir, '.claude-plugin', 'plugin.json');
|
|
36
|
+
const { version: troxyVersion } = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
37
|
+
fs.writeFileSync(manifest, JSON.stringify({ ...JSON.parse(fs.readFileSync(manifest, 'utf8')), version: troxyVersion }, null, 2) + '\n');
|
|
34
38
|
return true;
|
|
35
39
|
}
|
|
36
40
|
|
|
@@ -113,33 +113,81 @@ function troxyCommand(args, paused) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
const
|
|
116
|
+
// The band above the prompt: how much longer this session made the plan meter last.
|
|
117
|
+
// Each turn adds what it cost (the session's own /cost ledger) and how far it moved the
|
|
118
|
+
// 5h/7d meter; a routed turn also counts what it would have cost on the model asked
|
|
119
|
+
// for, at list prices. The account-wide view is the dashboard's.
|
|
120
|
+
const PRICE = { haiku: 1, sonnet: 2, opus: 4 }; // list $/MTok in; output keeps the same ratios
|
|
121
|
+
const WINDOW_LABEL = { five_hour: '5h', seven_day: '7d' };
|
|
122
|
+
// Drawn in Troxy's teal: the status line can't be colored and shows as a yellow warning.
|
|
123
|
+
const LINE = { plugin: 'troxy-guard', key: 'line' };
|
|
124
|
+
const SAVED = { plugin: 'troxy-guard', key: 'saved' }; // { paid, full, meter }; survives a troxy update
|
|
125
|
+
const TROXY_TEAL = '#0a9e88';
|
|
125
126
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
127
|
+
// The turn in flight: { start, ratio }, ratio = price of the model asked for / the one that ran.
|
|
128
|
+
let turn = null;
|
|
129
|
+
|
|
130
|
+
function bar(fraction) {
|
|
131
|
+
const filled = Math.max(0, Math.min(10, Math.round(fraction * 10)));
|
|
132
|
+
return `[${'█'.repeat(filled)}${'░'.repeat(10 - filled)}]`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// One line, most important first, e.g.
|
|
136
|
+
// [██████░░░░] This session: your bar lasts 1.7x longer · 5h: 19 instead of 32 · 7d: 12 instead of 20 · estimate
|
|
137
|
+
function savedLine(money, rateLimits = []) {
|
|
138
|
+
const paid = money && money.cost_paid_usd, kept = money && money.dollars_saved_usd;
|
|
139
|
+
if (!(paid > 0) || !(kept > 0)) return undefined;
|
|
140
|
+
const scale = (paid + kept) / paid;
|
|
141
|
+
const parts = [`This session: your bar lasts ${scale.toFixed(scale < 1.1 ? 2 : 1)}x longer`];
|
|
142
|
+
const windows = rateLimits.filter(w => WINDOW_LABEL[w.kind]);
|
|
143
|
+
if (windows.some(w => Math.round(w.percentUsed) >= 1)) {
|
|
144
|
+
for (const w of windows) {
|
|
145
|
+
const used = Math.round(w.percentUsed);
|
|
146
|
+
parts.push(`${WINDOW_LABEL[w.kind]}: ${used < 1 ? 'not moved yet' : `${used} instead of ${Math.round(used * scale)}`}`);
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
parts.push(`$${kept.toFixed(2)} saved of $${(paid + kept).toFixed(2)} at list price`);
|
|
150
|
+
}
|
|
151
|
+
parts.push('estimate');
|
|
152
|
+
return `${bar(1 / scale)} ${parts.join(' · ')}`;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function sessionLine(t) {
|
|
156
|
+
return t && savedLine({ cost_paid_usd: t.paid, dollars_saved_usd: t.full - t.paid },
|
|
157
|
+
Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// /troxy status: everything about this session's Troxy in one place.
|
|
161
|
+
function statusText({ version, paused, totals, rateLimits = [] }) {
|
|
162
|
+
const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
|
|
163
|
+
lines.push(sessionLine(totals) ? `This session: ${sessionLine(totals).replace(/^.*?This session: /, '')}`
|
|
164
|
+
: 'This session: nothing moved to a cheaper model yet');
|
|
165
|
+
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
166
|
+
if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
|
|
167
|
+
lines.push(`Prompts with over ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context stay on your model (switching would rewrite the cache).`);
|
|
168
|
+
lines.push('All sessions: dash.troxy.io');
|
|
169
|
+
lines.push(paused ? 'Type /troxy on to resume.' : 'Type /troxy off to pause routing.');
|
|
170
|
+
return lines.join('\n');
|
|
131
171
|
}
|
|
132
172
|
|
|
133
|
-
function
|
|
134
|
-
const
|
|
135
|
-
|
|
173
|
+
async function snapshot($) {
|
|
174
|
+
const u = await $.session.usage();
|
|
175
|
+
const meter = {};
|
|
176
|
+
for (const w of u.rateLimits || []) if (WINDOW_LABEL[w.kind]) meter[w.kind] = w.percentUsed;
|
|
177
|
+
return { cost: u.cost ? u.cost.usd : 0, meter };
|
|
136
178
|
}
|
|
137
179
|
|
|
138
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
180
|
+
// Adds one finished turn to the session's totals. Meter deltas are clamped at 0 (a
|
|
181
|
+
// window that reset mid-turn). ponytail: another session running at the same time
|
|
182
|
+
// moves the shared meter too; per-turn deltas keep that small, not zero.
|
|
183
|
+
function addTurn(totals, start, end, ratio) {
|
|
184
|
+
const t = totals || { paid: 0, full: 0, meter: {} };
|
|
185
|
+
const paid = Math.max(0, end.cost - start.cost);
|
|
186
|
+
const meter = { ...t.meter };
|
|
187
|
+
for (const k of Object.keys(end.meter)) {
|
|
188
|
+
if (k in start.meter) meter[k] = (meter[k] || 0) + Math.max(0, end.meter[k] - start.meter[k]);
|
|
189
|
+
}
|
|
190
|
+
return { paid: t.paid + paid, full: t.full + paid * ratio, meter };
|
|
143
191
|
}
|
|
144
192
|
|
|
145
193
|
// Where the local daemon looks for this session's routing note (see interceptor.js).
|
|
@@ -160,6 +208,7 @@ let decision = null;
|
|
|
160
208
|
|
|
161
209
|
async function scorePrompt($, e, next) {
|
|
162
210
|
decision = null;
|
|
211
|
+
turn = { start: await snapshot($), ratio: 1 };
|
|
163
212
|
const sid = await $.session.id();
|
|
164
213
|
const file = modRouteFile($.plugin.root, sid);
|
|
165
214
|
if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
|
|
@@ -171,7 +220,6 @@ async function scorePrompt($, e, next) {
|
|
|
171
220
|
);
|
|
172
221
|
const score = r.isAnswered ? parseScore(r.text) : null;
|
|
173
222
|
$.ui.log(`troxy scorer: ${r.isAnswered ? JSON.stringify(r.text) : r.reason} -> ${score}`, { to: 'debug' });
|
|
174
|
-
await addStats($, 1, 0, 1); // full cost until routeStep says otherwise
|
|
175
223
|
if (score !== null) {
|
|
176
224
|
const u = await $.session.usage();
|
|
177
225
|
decision = { score, tokens: estimateTokens(u.context && u.context.tokens, e.text) };
|
|
@@ -184,10 +232,6 @@ async function* routeStep($, e, next) {
|
|
|
184
232
|
if (!decision || e.agentId) return yield* next(e);
|
|
185
233
|
const model = pickModel(decision.score, e.model, decision.tokens);
|
|
186
234
|
if (model !== e.model) {
|
|
187
|
-
if (!decision.counted) {
|
|
188
|
-
decision.counted = true;
|
|
189
|
-
await addStats($, 0, 1, costRatio(e.model, model) - 1);
|
|
190
|
-
}
|
|
191
235
|
const sid = await $.session.id();
|
|
192
236
|
const file = modRouteFile($.plugin.root, sid);
|
|
193
237
|
const key = `${sid}:${e.model}>${model}`;
|
|
@@ -195,6 +239,7 @@ async function* routeStep($, e, next) {
|
|
|
195
239
|
lastNote = key;
|
|
196
240
|
await $.fs.write(file, JSON.stringify({ from: e.model, to: model, ts: await $.clock.now() }));
|
|
197
241
|
}
|
|
242
|
+
if (turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
|
|
198
243
|
}
|
|
199
244
|
return yield* next(model === e.model ? e : { ...e, model });
|
|
200
245
|
}
|
|
@@ -203,22 +248,49 @@ export function register(on) {
|
|
|
203
248
|
on('tool.call', { tool: 'Bash' }, guardBash).catch(failClosed);
|
|
204
249
|
on('session.start', async ($, e, next) => {
|
|
205
250
|
await $.command.register({ name: 'troxy', description: 'Pause or resume Troxy model routing', argumentHint: '[on|off|status]' });
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
251
|
+
// A resumed session would otherwise inherit its last routing note, and its background
|
|
252
|
+
// calls before the next prompt would show as savings on the dashboard.
|
|
253
|
+
const sid = await $.session.id();
|
|
254
|
+
const file = modRouteFile($.plugin.root, sid);
|
|
255
|
+
if (file) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); }
|
|
256
|
+
$.ui.status(undefined); // the bar moved to the band above the prompt
|
|
209
257
|
return next(e);
|
|
210
258
|
});
|
|
211
259
|
on('command.run', { command: 'troxy' }, async ($, e) => {
|
|
212
260
|
const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
|
|
213
261
|
await $.store.set('paused', r.paused);
|
|
214
|
-
if (
|
|
215
|
-
|
|
262
|
+
if (/^\s*(on|off)\s*$/i.test(String(e.args || ''))) return { text: r.text };
|
|
263
|
+
const manifest = JSON.parse(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`).catch(() => '{}'));
|
|
264
|
+
const { value: totals } = await $.state.get(SAVED);
|
|
265
|
+
return { text: statusText({ version: manifest.version, paused: r.paused, totals, rateLimits: (await $.session.usage()).rateLimits }) };
|
|
216
266
|
});
|
|
217
267
|
// Routing fails open: a scorer or hook error must never break the session,
|
|
218
268
|
// it just means the requested model is used as-is.
|
|
219
269
|
on('prompt.submit', scorePrompt).catch(($, e, next) => { decision = null; return next(e); });
|
|
220
270
|
on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
|
|
271
|
+
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
272
|
+
const { value: line } = await $.state.get(LINE); // subscribes the band to the line
|
|
273
|
+
if (!line || e.props.hasSurvey) return next(e);
|
|
274
|
+
const { Text } = $.ui.resolve(e);
|
|
275
|
+
return h(Text, { color: TROXY_TEAL }, `Troxy ${line}`);
|
|
276
|
+
});
|
|
277
|
+
// After the answer, so counting never delays a prompt.
|
|
278
|
+
on('turn.complete', async ($, e, next) => {
|
|
279
|
+
const r = await next(e);
|
|
280
|
+
if (turn) {
|
|
281
|
+
const { start, ratio } = turn;
|
|
282
|
+
turn = null;
|
|
283
|
+
try {
|
|
284
|
+
const { value } = await $.state.get(SAVED);
|
|
285
|
+
const totals = addTurn(value, start, await snapshot($), ratio);
|
|
286
|
+
await $.state.set(SAVED, totals);
|
|
287
|
+
const line = sessionLine(totals);
|
|
288
|
+
if (line) await $.state.set(LINE, line);
|
|
289
|
+
} catch {} // a missed turn only leaves the band as it was
|
|
290
|
+
}
|
|
291
|
+
return r;
|
|
292
|
+
});
|
|
221
293
|
}
|
|
222
294
|
|
|
223
295
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
224
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS,
|
|
296
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export type
|
|
1
|
+
export type BarLine = string | null
|
|
2
|
+
export type SessionSaved = { paid: number; full: number; meter: Record<string, number> }
|
|
2
3
|
|
|
3
4
|
declare module 'claude-code' {
|
|
4
5
|
interface PluginState {
|
|
5
|
-
'troxy-guard': {
|
|
6
|
+
'troxy-guard': { line: BarLine; saved: SessionSaved }
|
|
6
7
|
}
|
|
7
8
|
}
|
|
@@ -12,6 +12,9 @@ test('installTroxyGuardMod: copies the mod when the version supports it', async
|
|
|
12
12
|
assert.equal(installed, true);
|
|
13
13
|
assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json')));
|
|
14
14
|
assert.ok(fs.existsSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', 'hooks', 'register.js')));
|
|
15
|
+
// stamped with the CLI's version, which /troxy status shows
|
|
16
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'skills', 'troxy-guard', '.claude-plugin', 'plugin.json'), 'utf8'));
|
|
17
|
+
assert.equal(manifest.version, JSON.parse(fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version);
|
|
15
18
|
} finally {
|
|
16
19
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
17
20
|
}
|
|
@@ -63,12 +63,43 @@ test('real subprocess: a real Bash command still runs normally through the mod (
|
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
test('
|
|
67
|
-
const {
|
|
68
|
-
|
|
69
|
-
assert.equal(
|
|
70
|
-
|
|
71
|
-
assert.
|
|
72
|
-
|
|
73
|
-
assert.
|
|
66
|
+
test('savedLine: how much longer the bar lasts, then the meter or the dollars', () => {
|
|
67
|
+
const { savedLine } = _internal;
|
|
68
|
+
const meter = [{ kind: 'five_hour', percentUsed: 19 }, { kind: 'seven_day', percentUsed: 12.4 }];
|
|
69
|
+
assert.equal(savedLine({ cost_paid_usd: 19, dollars_saved_usd: 13 }, meter),
|
|
70
|
+
'[██████░░░░] This session: your bar lasts 1.7x longer · 5h: 19 instead of 32 · 7d: 12 instead of 20 · estimate');
|
|
71
|
+
assert.equal(savedLine({ cost_paid_usd: 92, dollars_saved_usd: 8 }, [{ kind: 'five_hour', percentUsed: 12 }, { kind: 'seven_day', percentUsed: 0 }]),
|
|
72
|
+
'[█████████░] This session: your bar lasts 1.09x longer · 5h: 12 instead of 13 · 7d: not moved yet · estimate');
|
|
73
|
+
assert.equal(savedLine({ cost_paid_usd: 1.64, dollars_saved_usd: 4.53 }, [{ kind: 'five_hour', percentUsed: 0.3 }]),
|
|
74
|
+
'[███░░░░░░░] This session: your bar lasts 3.8x longer · $4.53 saved of $6.17 at list price · estimate');
|
|
75
|
+
assert.equal(savedLine({ cost_paid_usd: 92, dollars_saved_usd: 8 }, []),
|
|
76
|
+
'[█████████░] This session: your bar lasts 1.09x longer · $8.00 saved of $100.00 at list price · estimate');
|
|
77
|
+
assert.equal(savedLine({ cost_paid_usd: 10, dollars_saved_usd: 0 }, meter), undefined);
|
|
78
|
+
assert.equal(savedLine(null, meter), undefined);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('addTurn: a session total from its own turns, a routed one counted at the asked-for price', () => {
|
|
82
|
+
const { addTurn, sessionLine } = _internal;
|
|
83
|
+
// Opus asked, Sonnet ran (ratio 2): $0.50 paid, $1.00 at Opus prices; meter moved 3 and 1.
|
|
84
|
+
let t = addTurn(undefined, { cost: 1, meter: { five_hour: 10, seven_day: 4 } }, { cost: 1.5, meter: { five_hour: 13, seven_day: 5 } }, 2);
|
|
85
|
+
assert.deepEqual(t, { paid: 0.5, full: 1, meter: { five_hour: 3, seven_day: 1 } });
|
|
86
|
+
// Not routed; the 5h window reset mid-turn, so its move counts as 0, not negative.
|
|
87
|
+
t = addTurn(t, { cost: 1.5, meter: { five_hour: 13, seven_day: 5 } }, { cost: 2, meter: { five_hour: 1, seven_day: 6 } }, 1);
|
|
88
|
+
assert.deepEqual(t, { paid: 1, full: 1.5, meter: { five_hour: 3, seven_day: 2 } });
|
|
89
|
+
assert.equal(sessionLine(t), '[███████░░░] This session: your bar lasts 1.5x longer · 5h: 3 instead of 5 · 7d: 2 instead of 3 · estimate');
|
|
90
|
+
// No routed turn yet: no band.
|
|
91
|
+
assert.equal(sessionLine(addTurn(undefined, { cost: 0, meter: {} }, { cost: 1, meter: {} }, 1)), undefined);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('statusText: version, routing, this session, plan usage, where the account view is', () => {
|
|
95
|
+
const { statusText } = _internal;
|
|
96
|
+
const totals = { paid: 0.03, full: 0.12, meter: {} };
|
|
97
|
+
assert.equal(statusText({ version: '1.29.42', paused: false, totals, rateLimits: [{ kind: 'five_hour', percentUsed: 23.4 }, { kind: 'seven_day', percentUsed: 77 }] }),
|
|
98
|
+
'Troxy 1.29.42 · routing is on\n'
|
|
99
|
+
+ 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
|
|
100
|
+
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
101
|
+
+ 'Prompts with over 50k tokens of context stay on your model (switching would rewrite the cache).\n'
|
|
102
|
+
+ 'All sessions: dash.troxy.io\n'
|
|
103
|
+
+ 'Type /troxy off to pause routing.');
|
|
104
|
+
assert.match(statusText({ paused: true }), /^Troxy · routing is off\nThis session: nothing moved to a cheaper model yet\n/);
|
|
74
105
|
});
|