troxy-cli 1.29.26 → 1.29.28

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.26",
3
+ "version": "1.29.28",
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": {
@@ -103,6 +103,23 @@ export function detectSurface(incomingHeaders) {
103
103
  // header is omitted entirely rather than forwarding a stale value.
104
104
  const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
105
105
  const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
106
+ // Written by the troxy-guard mod when it reroutes a prompt to a cheaper model:
107
+ // { from, to, ts } per session. Forwarded so the proxy can credit the saving to
108
+ // the model the user originally asked for (it only ever sees the rerouted one).
109
+ const _DEFAULT_MOD_ROUTE_DIR = path.join(os.homedir(), '.troxy', 'mod-route');
110
+ const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
111
+
112
+ function _readModRoute(dir, sessionId) {
113
+ try {
114
+ const file = sessionArmPath(dir, sessionId);
115
+ if (!file) return null;
116
+ const n = JSON.parse(fs.readFileSync(file, 'utf8'));
117
+ if (typeof n.from !== 'string' || typeof n.to !== 'string' || !(Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS)) return null;
118
+ return n;
119
+ } catch {
120
+ return null;
121
+ }
122
+ }
106
123
 
107
124
  // Returns the hook's last recorded state ('treated' | 'holdout' | 'skipped')
108
125
  // if the state file exists, parses, and was written within the freshness
@@ -135,7 +152,7 @@ function _readSessionArm(sandboxStateDir, sessionId) {
135
152
  }
136
153
  }
137
154
 
138
- export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR) {
155
+ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxStateDir = _DEFAULT_SANDBOX_STATE_DIR, modRouteDir = _DEFAULT_MOD_ROUTE_DIR) {
139
156
  const headers = {};
140
157
  for (const [k, v] of Object.entries(incomingHeaders)) {
141
158
  if (_HOP_BY_HOP_HEADERS.has(k) || k === 'authorization' || k === 'accept-encoding') continue;
@@ -154,6 +171,11 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
154
171
  ? _readSessionArm(sandboxStateDir, incomingHeaders['x-claude-code-session-id'])
155
172
  : _readFreshSandboxState(sandboxStateDir);
156
173
  if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
174
+ const modRoute = _readModRoute(modRouteDir, incomingHeaders['x-claude-code-session-id']);
175
+ if (modRoute) {
176
+ headers['x-troxy-mod-routed-from'] = modRoute.from;
177
+ headers['x-troxy-mod-routed-to'] = modRoute.to;
178
+ }
157
179
  headers.authorization = `Bearer ${troxyKey}`;
158
180
  return headers;
159
181
  }
@@ -48,7 +48,8 @@ async function failClosed($, e, next, err) {
48
48
 
49
49
  // --- Routing: extend the usage bar by sending easy prompts to a cheaper model.
50
50
  //
51
- // Ported from the proxy's scorer (troxy-tf-live model-proxy/app.py: bands,
51
+ // Ported from the proxy's scorer (troxy-tf-live model-proxy/app.py; bands match
52
+ // the production ROUTE_SCORER_BANDS=0.2:haiku,0.6:sonnet,0.8:opus,
52
53
  // never-upward rule, 50k context floor for Haiku). The mod scores each human
53
54
  // prompt once with the user's own session via $.model.complete, so there is no
54
55
  // Troxy round trip and no credential to carry. One decision per prompt, applied
@@ -60,7 +61,7 @@ const MODELS = {
60
61
  opus: 'claude-opus-5-5',
61
62
  };
62
63
  const RANK = { haiku: 0, sonnet: 1, opus: 2 };
63
- const BANDS = [[0.1, 'haiku'], [0.6, 'sonnet'], [0.8, 'opus']];
64
+ const BANDS = [[0.2, 'haiku'], [0.6, 'sonnet'], [0.8, 'opus']];
64
65
  const HAIKU_CONTEXT_FLOOR_TOKENS = 50000;
65
66
  const SCORE_TIMEOUT_MS = 3000;
66
67
  const SCORER_PROMPT_CHARS = 4000;
@@ -107,11 +108,27 @@ function troxyCommand(args, paused) {
107
108
  // Whether this session has shown its one "Troxy is active" notice yet.
108
109
  let announced = false;
109
110
 
111
+ // Where the local daemon looks for this session's routing note (see interceptor.js).
112
+ // Only when installed under ~/.claude/skills, so a --plugin-dir test run never writes
113
+ // into a real user's folder.
114
+ function modRouteFile(pluginRoot, sessionId) {
115
+ const suffix = '/.claude/skills/troxy-guard';
116
+ if (typeof pluginRoot !== 'string' || !pluginRoot.endsWith(suffix)) return null;
117
+ if (!/^[A-Za-z0-9-]{8,128}$/.test(String(sessionId))) return null;
118
+ return `${pluginRoot.slice(0, -suffix.length)}/.troxy/mod-route/sessions/${sessionId}.json`;
119
+ }
120
+
121
+ // What was last written, so the file is only rewritten when the note changes.
122
+ let lastNote = null;
123
+
110
124
  // The decision for the turn in flight: { score, tokens } or null (no change).
111
125
  let decision = null;
112
126
 
113
127
  async function scorePrompt($, e, next) {
114
128
  decision = null;
129
+ const sid = await $.session.id();
130
+ const file = modRouteFile($.plugin.root, sid);
131
+ if (file && lastNote !== `${sid}:{}`) { lastNote = `${sid}:{}`; await $.fs.write(file, '{}'); } // clear the previous prompt's note
115
132
  if (await $.store.get('paused')) return next(e);
116
133
  const kind = e.origin && e.origin.kind;
117
134
  if (kind !== 'composer' && kind !== 'bridge' && kind !== 'sdk') return next(e);
@@ -134,6 +151,15 @@ async function* routeStep($, e, next) {
134
151
  announced = true;
135
152
  $.ui.toast('Troxy is active');
136
153
  }
154
+ if (model !== e.model) {
155
+ const sid = await $.session.id();
156
+ const file = modRouteFile($.plugin.root, sid);
157
+ const key = `${sid}:${e.model}>${model}`;
158
+ if (file && lastNote !== key) {
159
+ lastNote = key;
160
+ await $.fs.write(file, JSON.stringify({ from: e.model, to: model, ts: await $.clock.now() }));
161
+ }
162
+ }
137
163
  return yield* next(model === e.model ? e : { ...e, model });
138
164
  }
139
165
 
@@ -155,4 +181,4 @@ export function register(on) {
155
181
  }
156
182
 
157
183
  // Exported for tests only - not part of the mod's runtime contract.
158
- export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, MODELS };
184
+ export const _internal = { toDomainAndFields, guardBash, failClosed, parseScore, pickModel, troxyCommand, modRouteFile, MODELS };
@@ -122,6 +122,35 @@ describe('buildTroxyForwardHeaders', () => {
122
122
  assert.equal(headers['x-troxy-tool-sandbox'], 'treated');
123
123
  });
124
124
 
125
+ describe('mod routing note', () => {
126
+ const SID = 'abcd1234-ef56-7890-abcd-ef1234567890';
127
+ const note = (dir, obj) => {
128
+ fs.mkdirSync(path.join(dir, 'sessions'), { recursive: true });
129
+ fs.writeFileSync(path.join(dir, 'sessions', `${SID}.json`), JSON.stringify(obj));
130
+ };
131
+ const build = (dir, sid = SID) => buildTroxyForwardHeaders(
132
+ { host: 'api.anthropic.com', 'x-claude-code-session-id': sid }, 'txy-key', undefined, dir);
133
+
134
+ it('adds from/to headers when the session has a fresh routing note', () => {
135
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
136
+ note(dir, { from: 'claude-opus-5-5', to: 'claude-haiku-4-5-20251001', ts: Date.now() });
137
+ const h = build(dir);
138
+ assert.equal(h['x-troxy-mod-routed-from'], 'claude-opus-5-5');
139
+ assert.equal(h['x-troxy-mod-routed-to'], 'claude-haiku-4-5-20251001');
140
+ });
141
+
142
+ it('adds nothing when there is no note, a cleared note, a stale note, or a bad session id', () => {
143
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
144
+ assert.equal(build(dir)['x-troxy-mod-routed-from'], undefined);
145
+ note(dir, {});
146
+ assert.equal(build(dir)['x-troxy-mod-routed-from'], undefined);
147
+ note(dir, { from: 'a', to: 'b', ts: Date.now() - 31 * 60 * 1000 });
148
+ assert.equal(build(dir)['x-troxy-mod-routed-from'], undefined);
149
+ note(dir, { from: 'a', to: 'b', ts: Date.now() });
150
+ assert.equal(build(dir, '../etc/passwd')['x-troxy-mod-routed-from'], undefined);
151
+ });
152
+ });
153
+
125
154
  it('omits x-troxy-tool-sandbox header when no state file exists', () => {
126
155
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-sandbox-'));
127
156
 
@@ -2,7 +2,7 @@ import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import { _internal } from '../mods/troxy-guard/hooks/register.js';
4
4
 
5
- const { parseScore, pickModel, troxyCommand, MODELS } = _internal;
5
+ const { parseScore, pickModel, troxyCommand, modRouteFile, MODELS } = _internal;
6
6
 
7
7
  test('parseScore: reads a bare number and clamps to 0..1', () => {
8
8
  assert.equal(parseScore('0.35'), 0.35);
@@ -16,8 +16,10 @@ test('parseScore: unparseable text is null so the caller keeps the requested mod
16
16
  assert.equal(parseScore('hard'), null);
17
17
  });
18
18
 
19
- test('pickModel: bands route down from opus (<=0.1 haiku, <=0.6 sonnet, <=0.8 opus)', () => {
19
+ test('pickModel: bands route down from opus (<=0.2 haiku, <=0.6 sonnet, <=0.8 opus)', () => {
20
20
  assert.equal(pickModel(0.05, MODELS.opus, 1000), MODELS.haiku);
21
+ assert.equal(pickModel(0.2, MODELS.opus, 1000), MODELS.haiku);
22
+ assert.equal(pickModel(0.25, MODELS.opus, 1000), MODELS.sonnet);
21
23
  assert.equal(pickModel(0.4, MODELS.opus, 1000), MODELS.sonnet);
22
24
  assert.equal(pickModel(0.7, MODELS.opus, 1000), MODELS.opus);
23
25
  assert.equal(pickModel(0.95, MODELS.opus, 1000), MODELS.opus);
@@ -52,3 +54,14 @@ test('troxyCommand: off and on flip the setting, status and anything else only r
52
54
  assert.match(troxyCommand('status', false).text, /on/);
53
55
  assert.equal(troxyCommand('nonsense', false).paused, false);
54
56
  });
57
+
58
+ test('modRouteFile: derives the daemon note path from the installed plugin folder', () => {
59
+ const sid = 'abcd1234-ef56-7890-abcd-ef1234567890';
60
+ assert.equal(modRouteFile('/Users/a/.claude/skills/troxy-guard', sid), `/Users/a/.troxy/mod-route/sessions/${sid}.json`);
61
+ });
62
+
63
+ test('modRouteFile: null when not installed in ~/.claude/skills (e.g. --plugin-dir) or the id is unsafe', () => {
64
+ const sid = 'abcd1234-ef56-7890-abcd-ef1234567890';
65
+ assert.equal(modRouteFile('/work/troxy/src/mods/troxy-guard', sid), null);
66
+ assert.equal(modRouteFile('/Users/a/.claude/skills/troxy-guard', '../x'), null);
67
+ });