troxy-cli 1.29.40 → 1.29.41

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.40",
3
+ "version": "1.29.41",
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": {
@@ -113,33 +113,68 @@ function troxyCommand(args, paused) {
113
113
  }
114
114
 
115
115
 
116
- // Session usage tracking, one count per human prompt (not per step).
117
- // Weights relative to Opus: list prices put Haiku at 0.25 and Sonnet at 0.5; a
118
- // Pro-plan meter test (30 prompts each) put Haiku at ~0.33. Haiku sits between.
119
- // ponytail: turn-weighted and ignores cache writes after a model switch, so it
120
- // is an estimate; weight by r.usage tokens if the number must be exact.
121
- const MODEL_COST = { haiku: 0.3, sonnet: 0.5, opus: 1.0 };
122
- // Kept in $.state (per session) so a `troxy update` hot reload keeps the counts.
123
- // cost: actual spend in "requested-model prompts".
124
- const STATS = { plugin: 'troxy-guard', key: 'stats' };
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
- async function addStats($, prompts, routed, cost) {
127
- const { value: s = { prompts: 0, routed: 0, cost: 0 } } = await $.state.get(STATS);
128
- const next = { prompts: s.prompts + prompts, routed: s.routed + routed, cost: s.cost + cost };
129
- await $.state.set(STATS, next);
130
- $.ui.status(usageLine(next));
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 })));
131
158
  }
132
159
 
133
- function costRatio(fromModel, toModel) {
134
- const f = MODEL_COST[tierOf(fromModel)], t = MODEL_COST[tierOf(toModel)];
135
- return f && t ? t / f : 1;
160
+ async function snapshot($) {
161
+ const u = await $.session.usage();
162
+ const meter = {};
163
+ for (const w of u.rateLimits || []) if (WINDOW_LABEL[w.kind]) meter[w.kind] = w.percentUsed;
164
+ return { cost: u.cost ? u.cost.usd : 0, meter };
136
165
  }
137
166
 
138
- // e.g. [████████░░░░░░░░░░░░] Troxy extended your usage by 87% · 2 of 3 prompts on a cheaper model
139
- function usageLine({ prompts, routed, cost }) {
140
- const extended = cost > 0 ? Math.round((prompts / cost - 1) * 100) : 0;
141
- const filled = prompts ? Math.round((routed / prompts) * 20) : 0;
142
- return `[${'█'.repeat(filled)}${'░'.repeat(20 - filled)}] Troxy extended your usage by ${extended}% · ${routed} of ${prompts} prompts on a cheaper model`;
167
+ // Adds one finished turn to the session's totals. Meter deltas are clamped at 0 (a
168
+ // window that reset mid-turn). ponytail: another session running at the same time
169
+ // moves the shared meter too; per-turn deltas keep that small, not zero.
170
+ function addTurn(totals, start, end, ratio) {
171
+ const t = totals || { paid: 0, full: 0, meter: {} };
172
+ const paid = Math.max(0, end.cost - start.cost);
173
+ const meter = { ...t.meter };
174
+ for (const k of Object.keys(end.meter)) {
175
+ if (k in start.meter) meter[k] = (meter[k] || 0) + Math.max(0, end.meter[k] - start.meter[k]);
176
+ }
177
+ return { paid: t.paid + paid, full: t.full + paid * ratio, meter };
143
178
  }
144
179
 
145
180
  // Where the local daemon looks for this session's routing note (see interceptor.js).
@@ -160,6 +195,7 @@ let decision = null;
160
195
 
161
196
  async function scorePrompt($, e, next) {
162
197
  decision = null;
198
+ turn = { start: await snapshot($), ratio: 1 };
163
199
  const sid = await $.session.id();
164
200
  const file = modRouteFile($.plugin.root, sid);
165
201
  if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
@@ -171,7 +207,6 @@ async function scorePrompt($, e, next) {
171
207
  );
172
208
  const score = r.isAnswered ? parseScore(r.text) : null;
173
209
  $.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
210
  if (score !== null) {
176
211
  const u = await $.session.usage();
177
212
  decision = { score, tokens: estimateTokens(u.context && u.context.tokens, e.text) };
@@ -184,10 +219,6 @@ async function* routeStep($, e, next) {
184
219
  if (!decision || e.agentId) return yield* next(e);
185
220
  const model = pickModel(decision.score, e.model, decision.tokens);
186
221
  if (model !== e.model) {
187
- if (!decision.counted) {
188
- decision.counted = true;
189
- await addStats($, 0, 1, costRatio(e.model, model) - 1);
190
- }
191
222
  const sid = await $.session.id();
192
223
  const file = modRouteFile($.plugin.root, sid);
193
224
  const key = `${sid}:${e.model}>${model}`;
@@ -195,6 +226,7 @@ async function* routeStep($, e, next) {
195
226
  lastNote = key;
196
227
  await $.fs.write(file, JSON.stringify({ from: e.model, to: model, ts: await $.clock.now() }));
197
228
  }
229
+ if (turn) turn.ratio = PRICE[tierOf(e.model)] / PRICE[tierOf(model)];
198
230
  }
199
231
  return yield* next(model === e.model ? e : { ...e, model });
200
232
  }
@@ -203,22 +235,46 @@ export function register(on) {
203
235
  on('tool.call', { tool: 'Bash' }, guardBash).catch(failClosed);
204
236
  on('session.start', async ($, e, next) => {
205
237
  await $.command.register({ name: 'troxy', description: 'Pause or resume Troxy model routing', argumentHint: '[on|off|status]' });
206
- // session.start fires again on a hot reload: put the bar back from the kept counts
207
- const { value } = await $.state.get(STATS);
208
- if (value && value.prompts && !(await $.store.get('paused'))) $.ui.status(usageLine(value));
238
+ // A resumed session would otherwise inherit its last routing note, and its background
239
+ // calls before the next prompt would show as savings on the dashboard.
240
+ const sid = await $.session.id();
241
+ const file = modRouteFile($.plugin.root, sid);
242
+ if (file) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); }
243
+ $.ui.status(undefined); // the bar moved to the band above the prompt
209
244
  return next(e);
210
245
  });
211
246
  on('command.run', { command: 'troxy' }, async ($, e) => {
212
247
  const r = troxyCommand(e.args, Boolean(await $.store.get('paused')));
213
248
  await $.store.set('paused', r.paused);
214
- if (r.paused) $.ui.status(undefined);
215
249
  return { text: r.text };
216
250
  });
217
251
  // Routing fails open: a scorer or hook error must never break the session,
218
252
  // it just means the requested model is used as-is.
219
253
  on('prompt.submit', scorePrompt).catch(($, e, next) => { decision = null; return next(e); });
220
254
  on('turn.step', routeStep).catch(async function* ($, e, next) { return yield* next(e); });
255
+ on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
256
+ const { value: line } = await $.state.get(LINE); // subscribes the band to the line
257
+ if (!line || e.props.hasSurvey) return next(e);
258
+ const { Text } = $.ui.resolve(e);
259
+ return h(Text, { color: TROXY_TEAL }, `Troxy ${line}`);
260
+ });
261
+ // After the answer, so counting never delays a prompt.
262
+ on('turn.complete', async ($, e, next) => {
263
+ const r = await next(e);
264
+ if (turn) {
265
+ const { start, ratio } = turn;
266
+ turn = null;
267
+ try {
268
+ const { value } = await $.state.get(SAVED);
269
+ const totals = addTurn(value, start, await snapshot($), ratio);
270
+ await $.state.set(SAVED, totals);
271
+ const line = sessionLine(totals);
272
+ if (line) await $.state.set(LINE, line);
273
+ } catch {} // a missed turn only leaves the band as it was
274
+ }
275
+ return r;
276
+ });
221
277
  }
222
278
 
223
279
  // 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, usageLine, costRatio };
280
+ export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn };
@@ -1,7 +1,8 @@
1
- export type UsageStats = { prompts: number; routed: number; cost: number }
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': { stats: UsageStats }
6
+ 'troxy-guard': { line: BarLine; saved: SessionSaved }
6
7
  }
7
8
  }
@@ -63,12 +63,30 @@ test('real subprocess: a real Bash command still runs normally through the mod (
63
63
  }
64
64
  });
65
65
 
66
- test('usageLine: counts every prompt, extension reflects routed share', () => {
67
- const { usageLine, costRatio, MODELS } = _internal;
68
- assert.equal(costRatio(MODELS.opus, MODELS.haiku), 0.3);
69
- assert.equal(costRatio('some-new-model', MODELS.haiku), 1);
70
- // 3 prompts, 2 routed opus->haiku: cost 1 + 0.3 + 0.3 = 1.6, 3/1.6 = +88%
71
- assert.match(usageLine({ prompts: 3, routed: 2, cost: 1.6 }), /extended your usage by 88% · 2 of 3 prompts/);
72
- assert.match(usageLine({ prompts: 3, routed: 0, cost: 3 }), /by 0% · 0 of 3/);
73
- assert.match(usageLine({ prompts: 0, routed: 0, cost: 0 }), /by 0% · 0 of 0/);
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);
74
92
  });