troxy-cli 1.29.43 → 1.29.45
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.45",
|
|
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": {
|
|
@@ -24,7 +24,7 @@ async function guardBash($, e, next) {
|
|
|
24
24
|
body: JSON.stringify({ domain: mapped.domain, fields: mapped.fields }),
|
|
25
25
|
signal: controller.signal,
|
|
26
26
|
});
|
|
27
|
-
result = res.ok ?
|
|
27
|
+
result = res.ok ? JSON.parse(res.text) : { decision: 'ESCALATE', reason: 'policy service returned an error' };
|
|
28
28
|
} finally {
|
|
29
29
|
clearTimeout(timer);
|
|
30
30
|
}
|
|
@@ -121,6 +121,7 @@ const PRICE = { haiku: 1, sonnet: 2, opus: 4 }; // list $/MTok in; output keeps
|
|
|
121
121
|
const WINDOW_LABEL = { five_hour: '5h', seven_day: '7d' };
|
|
122
122
|
// Drawn in Troxy's teal: the status line can't be colored and shows as a yellow warning.
|
|
123
123
|
const LINE = { plugin: 'troxy-guard', key: 'line' };
|
|
124
|
+
const SAVINGS = { plugin: 'troxy-guard', key: 'savings' }; // the band's compression + trimming text
|
|
124
125
|
const SAVED = { plugin: 'troxy-guard', key: 'saved' }; // { paid, full, meter }; survives a troxy update
|
|
125
126
|
const TROXY_TEAL = '#0a9e88';
|
|
126
127
|
|
|
@@ -157,6 +158,39 @@ function sessionLine(t) {
|
|
|
157
158
|
Object.entries(t.meter).map(([kind, percentUsed]) => ({ kind, percentUsed })));
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
// Tokens Troxy kept out of the requests of this session (compression + trimming), counted
|
|
162
|
+
// by the server. undefined until something was saved.
|
|
163
|
+
function savingsText(s) {
|
|
164
|
+
const c = (s && s.compressed_tokens) || 0, t = (s && s.trimmed_tokens) || 0;
|
|
165
|
+
if (!(c + t > 0)) return undefined;
|
|
166
|
+
const k = n => n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
167
|
+
return `${k(c + t)} tokens saved by compression and trimming`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// This session's savings from the server, or null when there is no key, no session id or
|
|
171
|
+
// the call fails (the band then keeps what it showed).
|
|
172
|
+
async function fetchSavings($) {
|
|
173
|
+
const sid = await $.session.id();
|
|
174
|
+
if (!/^[A-Za-z0-9-]{8,64}$/.test(String(sid))) return null;
|
|
175
|
+
let key = await $.env.get('TROXY_API_KEY');
|
|
176
|
+
if (!key) {
|
|
177
|
+
const home = await $.env.get('HOME');
|
|
178
|
+
if (!home) return null;
|
|
179
|
+
key = JSON.parse(await $.fs.read(`${home}/.troxy/config.json`)).apiKey;
|
|
180
|
+
}
|
|
181
|
+
if (!key) return null;
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
184
|
+
try {
|
|
185
|
+
const res = await $.http.fetch(`https://api.troxy.io/mcp/session-savings?session_id=${sid}`, {
|
|
186
|
+
headers: { 'x-troxy-key': key }, signal: controller.signal,
|
|
187
|
+
});
|
|
188
|
+
return res.ok ? JSON.parse(res.text) : null;
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
160
194
|
// /troxy status: everything about this session's Troxy in one place.
|
|
161
195
|
function statusText({ version, paused, totals, rateLimits = [], compactOff = false }) {
|
|
162
196
|
const lines = [`Troxy ${version || ''}`.trim() + ` · routing is ${paused ? 'off' : 'on'}`];
|
|
@@ -275,12 +309,20 @@ async function scorePrompt($, e, next) {
|
|
|
275
309
|
return next(e);
|
|
276
310
|
}
|
|
277
311
|
|
|
312
|
+
// The model the session last asked for, per session, so a /model switch is seen.
|
|
313
|
+
const lastAsked = new Map();
|
|
314
|
+
|
|
278
315
|
async function* routeStep($, e, next) {
|
|
279
316
|
// Main loop only: a subagent keeps whatever model it was given.
|
|
280
317
|
if (!decision || e.agentId) return yield* next(e);
|
|
318
|
+
// The user picked a different model since the last turn: their choice wins
|
|
319
|
+
// for this turn (the proxy does the same, route_reason 'user_switch').
|
|
320
|
+
const sid = await $.session.id();
|
|
321
|
+
const prev = lastAsked.get(sid);
|
|
322
|
+
lastAsked.set(sid, e.model);
|
|
323
|
+
if (prev && prev !== e.model) { decision = null; return yield* next(e); }
|
|
281
324
|
const model = pickModel(decision.score, e.model, decision.tokens);
|
|
282
325
|
if (model !== e.model) {
|
|
283
|
-
const sid = await $.session.id();
|
|
284
326
|
const file = modRouteFile($.plugin.root, sid);
|
|
285
327
|
const key = `${sid}:${e.model}>${model}`;
|
|
286
328
|
if (file && lastNote !== key) {
|
|
@@ -325,9 +367,10 @@ export function register(on) {
|
|
|
325
367
|
on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
|
|
326
368
|
const { value: line } = await $.state.get(LINE); // subscribes the band to the line
|
|
327
369
|
const { value: note } = await $.state.get(COMPACTED);
|
|
328
|
-
|
|
370
|
+
const { value: kept } = await $.state.get(SAVINGS);
|
|
371
|
+
if ((!line && !kept && !note) || e.props.hasSurvey) return next(e);
|
|
329
372
|
const { Text } = $.ui.resolve(e);
|
|
330
|
-
return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, note].filter(Boolean).join(' · ')}`);
|
|
373
|
+
return h(Text, { color: TROXY_TEAL }, `Troxy ${[line, kept, note].filter(Boolean).join(' · ')}`);
|
|
331
374
|
});
|
|
332
375
|
// After the answer, so counting never delays a prompt.
|
|
333
376
|
on('turn.complete', async ($, e, next) => {
|
|
@@ -343,10 +386,14 @@ export function register(on) {
|
|
|
343
386
|
if (line) await $.state.set(LINE, line);
|
|
344
387
|
} catch {} // a missed turn only leaves the band as it was
|
|
345
388
|
}
|
|
389
|
+
try {
|
|
390
|
+
const text = savingsText(await fetchSavings($));
|
|
391
|
+
if (text) await $.state.set(SAVINGS, text);
|
|
392
|
+
} catch {} // a missed fetch only leaves the band as it was
|
|
346
393
|
armCompact($, COMPACT_IDLE_MS);
|
|
347
394
|
return r;
|
|
348
395
|
});
|
|
349
396
|
}
|
|
350
397
|
|
|
351
398
|
// Exported for tests only - not part of the mod's runtime contract.
|
|
352
|
-
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan };
|
|
399
|
+
export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep };
|
|
@@ -3,6 +3,6 @@ export type SessionSaved = { paid: number; full: number; meter: Record<string, n
|
|
|
3
3
|
|
|
4
4
|
declare module 'claude-code' {
|
|
5
5
|
interface PluginState {
|
|
6
|
-
'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine }
|
|
6
|
+
'troxy-guard': { line: BarLine; saved: SessionSaved; compacted: BarLine; savings: BarLine }
|
|
7
7
|
}
|
|
8
8
|
}
|
|
@@ -118,3 +118,60 @@ test('compactPlan: big idle sessions compact, small ones and a nearly full bar d
|
|
|
118
118
|
// nearly full with no known reset: never spend it
|
|
119
119
|
assert.deepEqual(compactPlan({ tokens: 210000, now, rateLimits: [{ kind: 'seven_day', percentUsed: 99 }] }), { action: 'skip' });
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
test('savingsText: shows compression + trimming together, nothing when nothing was saved', () => {
|
|
123
|
+
const { savingsText } = _internal;
|
|
124
|
+
assert.equal(savingsText({ compressed_tokens: 5200, trimmed_tokens: 40100 }), '45k tokens saved by compression and trimming');
|
|
125
|
+
assert.equal(savingsText({ compressed_tokens: 300, trimmed_tokens: 0 }), '300 tokens saved by compression and trimming');
|
|
126
|
+
assert.equal(savingsText({ compressed_tokens: 0, trimmed_tokens: 0 }), undefined);
|
|
127
|
+
assert.equal(savingsText(null), undefined);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('fetchSavings: sends the saved key and the session id; null without a key or on an error', async () => {
|
|
131
|
+
const { fetchSavings } = _internal;
|
|
132
|
+
const calls = [];
|
|
133
|
+
const mk = ({ env = {}, config, res = { ok: true, text: '{"compressed_tokens":1}' } } = {}) => ({
|
|
134
|
+
session: { id: async () => 'abc-12345678' },
|
|
135
|
+
env: { get: async n => env[n] },
|
|
136
|
+
fs: { read: async p => { if (!config) throw new Error('missing'); calls.push(p); return JSON.stringify(config); } },
|
|
137
|
+
http: { fetch: async (url, init) => { calls.push([url, init.headers]); return res; } },
|
|
138
|
+
});
|
|
139
|
+
assert.deepEqual(await fetchSavings(mk({ env: { HOME: '/h' }, config: { apiKey: 'txy-1' } })), { compressed_tokens: 1 });
|
|
140
|
+
assert.deepEqual(calls, ['/h/.troxy/config.json', ['https://api.troxy.io/mcp/session-savings?session_id=abc-12345678', { 'x-troxy-key': 'txy-1' }]]);
|
|
141
|
+
assert.equal(await fetchSavings(mk({ env: { TROXY_API_KEY: 'txy-2' }, res: { ok: false } })), null);
|
|
142
|
+
assert.equal(await fetchSavings(mk({ env: { HOME: '/h' }, config: {} })), null);
|
|
143
|
+
assert.equal(await fetchSavings({ ...mk(), session: { id: async () => 'x' } }), null);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// A fake $ just big enough for scorePrompt + routeStep: the scorer says "easy"
|
|
147
|
+
// (score 0.1) and the context is small, so the mod would route down to haiku.
|
|
148
|
+
function routingApi() {
|
|
149
|
+
const kv = new Map();
|
|
150
|
+
return {
|
|
151
|
+
state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
|
|
152
|
+
store: { get: async () => undefined },
|
|
153
|
+
session: { id: async () => 'sess-switch-test', usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
|
|
154
|
+
plugin: { root: '/tmp/not-installed' },
|
|
155
|
+
model: { complete: async () => ({ isAnswered: true, text: '0.1' }) },
|
|
156
|
+
ui: { log: () => {} },
|
|
157
|
+
clock: { now: async () => 0 },
|
|
158
|
+
fs: { write: async () => {} },
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function runTurn($, model) {
|
|
163
|
+
const { scorePrompt, routeStep } = _internal;
|
|
164
|
+
await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
|
|
165
|
+
let sent;
|
|
166
|
+
const gen = routeStep($, { model }, async function* (e) { sent = e.model; });
|
|
167
|
+
for await (const _ of gen) { /* drain */ }
|
|
168
|
+
return sent;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
test('routeStep: a /model switch is honored for that turn, then routing resumes', async () => {
|
|
172
|
+
const $ = routingApi();
|
|
173
|
+
const opus = _internal.MODELS.opus, sonnet = _internal.MODELS.sonnet, haiku = _internal.MODELS.haiku;
|
|
174
|
+
assert.equal(await runTurn($, opus), haiku); // first turn: routed down as before
|
|
175
|
+
assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
|
|
176
|
+
assert.equal(await runTurn($, sonnet), haiku); // same choice again: routing resumes
|
|
177
|
+
});
|