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.43",
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 ? await res.json() : { decision: 'ESCALATE', reason: 'policy service returned an error' };
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
- if ((!line && !note) || e.props.hasSurvey) return next(e);
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
+ });