troxy-cli 1.29.45 → 1.29.47
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.47",
|
|
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/daemon.js
CHANGED
|
@@ -105,7 +105,7 @@ export function startInterceptor({
|
|
|
105
105
|
routeResolver: (host, method, reqPath) => troxyRouteFor(providers, host, method, reqPath),
|
|
106
106
|
// Fresh from disk on every request, not cached at startup - a
|
|
107
107
|
// `troxy rotate-key` takes effect immediately, no daemon restart needed.
|
|
108
|
-
getTroxyKey: () => loadConfig()?.apiKey || null,
|
|
108
|
+
getTroxyKey: () => loadConfig()?.apiKey?.trim() || null,
|
|
109
109
|
getCerts: () => certs,
|
|
110
110
|
log: _log,
|
|
111
111
|
});
|
package/src/interceptor.js
CHANGED
|
@@ -104,7 +104,7 @@ export function detectSurface(incomingHeaders) {
|
|
|
104
104
|
const _SANDBOX_STATE_FRESHNESS_MS = 2 * 60 * 1000;
|
|
105
105
|
const _DEFAULT_SANDBOX_STATE_DIR = path.join(os.homedir(), '.troxy', 'sandbox');
|
|
106
106
|
// Written by the troxy-guard mod when it reroutes a prompt to a cheaper model:
|
|
107
|
-
// {
|
|
107
|
+
// { arm, from?, to?, ts } per session. Forwarded so the proxy can credit the saving to
|
|
108
108
|
// the model the user originally asked for (it only ever sees the rerouted one).
|
|
109
109
|
const _DEFAULT_MOD_ROUTE_DIR = path.join(os.homedir(), '.troxy', 'mod-route');
|
|
110
110
|
const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
|
|
@@ -112,12 +112,15 @@ const _MOD_ROUTE_MAX_AGE_MS = 30 * 60 * 1000;
|
|
|
112
112
|
function _readModRoute(dir, sessionId) {
|
|
113
113
|
try {
|
|
114
114
|
const file = sessionArmPath(dir, sessionId);
|
|
115
|
-
if (!file) return null;
|
|
115
|
+
if (!file) return { arm: null, from: null, to: null };
|
|
116
116
|
const n = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
// arm is per-session state and never expires; from/to is only valid if fresh.
|
|
118
|
+
const arm = n.arm === 'treated' || n.arm === 'holdout' ? n.arm : null;
|
|
119
|
+
const isFresh = Date.now() - n.ts < _MOD_ROUTE_MAX_AGE_MS;
|
|
120
|
+
const routed = isFresh && typeof n.from === 'string' && typeof n.to === 'string';
|
|
121
|
+
return { arm, from: routed ? n.from : null, to: routed ? n.to : null };
|
|
119
122
|
} catch {
|
|
120
|
-
return null;
|
|
123
|
+
return { arm: null, from: null, to: null };
|
|
121
124
|
}
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -172,10 +175,12 @@ export function buildTroxyForwardHeaders(incomingHeaders, troxyKey, sandboxState
|
|
|
172
175
|
: _readFreshSandboxState(sandboxStateDir);
|
|
173
176
|
if (sandboxState) headers['x-troxy-tool-sandbox'] = sandboxState;
|
|
174
177
|
const modRoute = _readModRoute(modRouteDir, incomingHeaders['x-claude-code-session-id']);
|
|
175
|
-
if (modRoute) {
|
|
178
|
+
if (modRoute && modRoute.from) {
|
|
176
179
|
headers['x-troxy-mod-routed-from'] = modRoute.from;
|
|
177
180
|
headers['x-troxy-mod-routed-to'] = modRoute.to;
|
|
178
181
|
}
|
|
182
|
+
// The mod is this session's one router: the proxy must not route it, and logs the arm.
|
|
183
|
+
if (modRoute && modRoute.arm) headers['x-troxy-mod-arm'] = modRoute.arm;
|
|
179
184
|
headers.authorization = `Bearer ${troxyKey}`;
|
|
180
185
|
return headers;
|
|
181
186
|
}
|
|
@@ -279,13 +284,15 @@ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, respon
|
|
|
279
284
|
resolve(result);
|
|
280
285
|
};
|
|
281
286
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
287
|
+
let upstreamReq;
|
|
288
|
+
try {
|
|
289
|
+
upstreamReq = _clientForProtocol(route.upstream).request({
|
|
290
|
+
method: req.method,
|
|
291
|
+
hostname: url.hostname,
|
|
292
|
+
port: url.port || (url.protocol === 'http:' ? 80 : 443),
|
|
293
|
+
path: url.pathname + search,
|
|
294
|
+
headers: buildTroxyForwardHeaders(req.headers, troxyKey),
|
|
295
|
+
}, (upstreamRes) => {
|
|
289
296
|
if (settled) return; // already failed open on a timeout; drop this late arrival
|
|
290
297
|
settled = true;
|
|
291
298
|
clearTimeout(connectTimer);
|
|
@@ -328,6 +335,11 @@ function _relayToTroxy(route, req, res, body, troxyKey, connectTimeoutMs, respon
|
|
|
328
335
|
|
|
329
336
|
upstreamReq.on('error', () => settle(false));
|
|
330
337
|
upstreamReq.end(body);
|
|
338
|
+
} catch (err) {
|
|
339
|
+
// Invalid header value (e.g. a trailing newline in the key) throws synchronously: fail open.
|
|
340
|
+
log('troxy request rejected before sending: %s', err.message);
|
|
341
|
+
settle(false);
|
|
342
|
+
}
|
|
331
343
|
});
|
|
332
344
|
}
|
|
333
345
|
|
|
@@ -91,16 +91,27 @@ function parseScore(text) {
|
|
|
91
91
|
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : null;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
function pickModel(score, requested, contextTokens) {
|
|
94
|
+
function pickModel(score, requested, contextTokens, warm) {
|
|
95
95
|
const rq = tierOf(requested);
|
|
96
96
|
if (rq === null) return requested; // unknown family (e.g. a newer tier): leave it alone
|
|
97
|
+
// No score (scorer failed or echoed the prompt): stay on the warm model at any
|
|
98
|
+
// size, since a switch would re-write the cache for no reason.
|
|
99
|
+
if (score === null) {
|
|
100
|
+
const w = tierOf(warm);
|
|
101
|
+
return w !== null && RANK[w] <= RANK[rq] ? warm : requested;
|
|
102
|
+
}
|
|
97
103
|
let tier = rq;
|
|
98
104
|
for (const [limit, name] of BANDS) {
|
|
99
105
|
if (score <= limit) { if (RANK[name] < RANK[rq]) tier = name; break; }
|
|
100
106
|
}
|
|
101
107
|
// The cache is per model: the first prompt on a new model re-writes the whole
|
|
102
|
-
// conversation, so past the floor
|
|
103
|
-
|
|
108
|
+
// conversation, so past the floor stay on the model the cache is warm on
|
|
109
|
+
// (`warm`, what this session last ran), unless the prompt needs a stronger one.
|
|
110
|
+
if (contextTokens > ROUTE_CONTEXT_FLOOR_TOKENS) {
|
|
111
|
+
const w = tierOf(warm);
|
|
112
|
+
if (w === null || RANK[w] > RANK[rq]) tier = rq;
|
|
113
|
+
else if (RANK[tier] <= RANK[w]) tier = w;
|
|
114
|
+
}
|
|
104
115
|
return tier === rq ? requested : MODELS[tier];
|
|
105
116
|
}
|
|
106
117
|
|
|
@@ -198,7 +209,7 @@ function statusText({ version, paused, totals, rateLimits = [], compactOff = fal
|
|
|
198
209
|
: 'This session: nothing moved to a cheaper model yet');
|
|
199
210
|
const meter = rateLimits.filter(w => WINDOW_LABEL[w.kind]).map(w => `${WINDOW_LABEL[w.kind]} ${Math.round(w.percentUsed)}%`);
|
|
200
211
|
if (meter.length) lines.push(`Plan usage now: ${meter.join(' · ')}`);
|
|
201
|
-
lines.push(`
|
|
212
|
+
lines.push(`Past ${ROUTE_CONTEXT_FLOOR_TOKENS / 1000}k tokens of context a session stays on the model it is on (switching would rewrite the cache), unless a prompt needs a stronger one.`);
|
|
202
213
|
lines.push(compactOff ? 'Idle compaction is off (/troxy compact on).'
|
|
203
214
|
: `Idle compaction is on: sessions over ${COMPACT_MIN_TOKENS / 1000}k tokens are compacted after ${COMPACT_IDLE_MS / 60000} min idle (/troxy compact off).`);
|
|
204
215
|
lines.push('All sessions: dash.troxy.io');
|
|
@@ -302,26 +313,31 @@ async function scorePrompt($, e, next) {
|
|
|
302
313
|
);
|
|
303
314
|
const score = r.isAnswered ? parseScore(r.text) : null;
|
|
304
315
|
$.ui.log(`troxy scorer: ${r.isAnswered ? JSON.stringify(r.text) : r.reason} -> ${score}`, { to: 'debug' });
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
decision = { score, tokens: estimateTokens(u.context && u.context.tokens, e.text) };
|
|
308
|
-
}
|
|
316
|
+
const u = await $.session.usage();
|
|
317
|
+
decision = { score, tokens: estimateTokens(u.context && u.context.tokens, e.text) };
|
|
309
318
|
return next(e);
|
|
310
319
|
}
|
|
311
320
|
|
|
312
321
|
// The model the session last asked for, per session, so a /model switch is seen.
|
|
313
322
|
const lastAsked = new Map();
|
|
323
|
+
// The model each session last ran on and when: its cache is warm for about an hour.
|
|
324
|
+
const lastSent = new Map();
|
|
325
|
+
const WARM_MS = 55 * 60 * 1000;
|
|
314
326
|
|
|
315
327
|
async function* routeStep($, e, next) {
|
|
316
328
|
// Main loop only: a subagent keeps whatever model it was given.
|
|
317
|
-
if (
|
|
329
|
+
if (e.agentId) return yield* next(e);
|
|
330
|
+
const sid = await $.session.id();
|
|
331
|
+
const now = await $.clock.now();
|
|
318
332
|
// The user picked a different model since the last turn: their choice wins
|
|
319
333
|
// for this turn (the proxy does the same, route_reason 'user_switch').
|
|
320
|
-
const sid = await $.session.id();
|
|
321
334
|
const prev = lastAsked.get(sid);
|
|
322
335
|
lastAsked.set(sid, e.model);
|
|
323
|
-
if (prev && prev !== e.model)
|
|
324
|
-
|
|
336
|
+
if (prev && prev !== e.model) decision = null;
|
|
337
|
+
if (!decision) { lastSent.set(sid, { model: e.model, at: now }); return yield* next(e); }
|
|
338
|
+
const ls = lastSent.get(sid);
|
|
339
|
+
const model = pickModel(decision.score, e.model, decision.tokens, ls && now - ls.at < WARM_MS ? ls.model : null);
|
|
340
|
+
lastSent.set(sid, { model, at: now });
|
|
325
341
|
if (model !== e.model) {
|
|
326
342
|
const file = modRouteFile($.plugin.root, sid);
|
|
327
343
|
const key = `${sid}:${e.model}>${model}`;
|
|
@@ -149,6 +149,20 @@ describe('buildTroxyForwardHeaders', () => {
|
|
|
149
149
|
note(dir, { from: 'a', to: 'b', ts: Date.now() });
|
|
150
150
|
assert.equal(build(dir, '../etc/passwd')['x-troxy-mod-routed-from'], undefined);
|
|
151
151
|
});
|
|
152
|
+
|
|
153
|
+
it('tags the arm on every call, with or without a reroute', () => {
|
|
154
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-modroute-'));
|
|
155
|
+
note(dir, { arm: 'holdout', ts: Date.now() });
|
|
156
|
+
let h = build(dir);
|
|
157
|
+
assert.equal(h['x-troxy-mod-arm'], 'holdout');
|
|
158
|
+
assert.equal(h['x-troxy-mod-routed-from'], undefined);
|
|
159
|
+
note(dir, { arm: 'treated', from: 'claude-opus-5-5', to: 'claude-haiku-4-5-20251001', ts: Date.now() });
|
|
160
|
+
h = build(dir);
|
|
161
|
+
assert.equal(h['x-troxy-mod-arm'], 'treated');
|
|
162
|
+
assert.equal(h['x-troxy-mod-routed-to'], 'claude-haiku-4-5-20251001');
|
|
163
|
+
note(dir, { arm: 'bogus', ts: Date.now() });
|
|
164
|
+
assert.equal(build(dir)['x-troxy-mod-arm'], undefined);
|
|
165
|
+
});
|
|
152
166
|
});
|
|
153
167
|
|
|
154
168
|
it('omits x-troxy-tool-sandbox header when no state file exists', () => {
|
|
@@ -702,3 +716,58 @@ describe('buildTroxyForwardHeaders session arm', () => {
|
|
|
702
716
|
assert.equal('x-troxy-tool-sandbox' in headers, false);
|
|
703
717
|
});
|
|
704
718
|
});
|
|
719
|
+
|
|
720
|
+
describe('interceptor fails open on invalid header values', () => {
|
|
721
|
+
const openServers = [];
|
|
722
|
+
after(async () => {
|
|
723
|
+
openServers.forEach(s => s.close());
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('handles API key with trailing newline gracefully without crashing', async () => {
|
|
727
|
+
// Simulates daemon.js config loading a key with a trailing newline,
|
|
728
|
+
// before the trim fix. The interceptor must fail open, not crash.
|
|
729
|
+
const targetServer = net.createServer((socket) => {
|
|
730
|
+
socket.write('HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK');
|
|
731
|
+
socket.end();
|
|
732
|
+
});
|
|
733
|
+
await new Promise(r => targetServer.listen(0, '127.0.0.1', r));
|
|
734
|
+
openServers.push(targetServer);
|
|
735
|
+
const targetPort = targetServer.address().port;
|
|
736
|
+
|
|
737
|
+
const interceptor = createInterceptor({
|
|
738
|
+
interceptHosts: ['api.anthropic.com'],
|
|
739
|
+
routeResolver: (host, method, reqPath) => {
|
|
740
|
+
if (host === 'api.anthropic.com') {
|
|
741
|
+
return { method, path: '/v1/messages', upstream: `http://127.0.0.1:${targetPort}` };
|
|
742
|
+
}
|
|
743
|
+
return null;
|
|
744
|
+
},
|
|
745
|
+
// Return a key with trailing newline - simulates the bug before daemon.js trim
|
|
746
|
+
getTroxyKey: () => 'txy-test-key\n',
|
|
747
|
+
getCerts: () => null, // No TLS for this test, it goes straight to HTTP upstream
|
|
748
|
+
log: () => {},
|
|
749
|
+
});
|
|
750
|
+
await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
|
|
751
|
+
openServers.push(interceptor);
|
|
752
|
+
|
|
753
|
+
// Make a request through the proxy to the anthropic host
|
|
754
|
+
// The invalid header should cause a catch in _relayToTroxy, which should
|
|
755
|
+
// settle(false) to fail open, not crash the server.
|
|
756
|
+
const response = await new Promise((resolve, reject) => {
|
|
757
|
+
const req = net.createConnection(
|
|
758
|
+
{ host: '127.0.0.1', port: interceptor.address().port },
|
|
759
|
+
() => {
|
|
760
|
+
req.write('GET / HTTP/1.1\r\nHost: api.anthropic.com\r\nConnection: close\r\n\r\n');
|
|
761
|
+
}
|
|
762
|
+
);
|
|
763
|
+
let body = '';
|
|
764
|
+
req.on('data', (chunk) => { body += chunk.toString(); });
|
|
765
|
+
req.on('end', () => resolve(body));
|
|
766
|
+
req.on('error', reject);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// Verify we got a response (either direct relay or error response),
|
|
770
|
+
// and the server didn't crash
|
|
771
|
+
assert.ok(response.length > 0, 'should get a response, not crash');
|
|
772
|
+
});
|
|
773
|
+
});
|
|
@@ -98,7 +98,7 @@ test('statusText: version, routing, this session, plan usage, where the account
|
|
|
98
98
|
'Troxy 1.29.42 · routing is on\n'
|
|
99
99
|
+ 'This session: your bar lasts 4.0x longer · $0.09 saved of $0.12 at list price · estimate\n'
|
|
100
100
|
+ 'Plan usage now: 5h 23% · 7d 77%\n'
|
|
101
|
-
+ '
|
|
101
|
+
+ 'Past 50k tokens of context a session stays on the model it is on (switching would rewrite the cache), unless a prompt needs a stronger one.\n'
|
|
102
102
|
+ 'Idle compaction is on: sessions over 100k tokens are compacted after 55 min idle (/troxy compact off).\n'
|
|
103
103
|
+ 'All sessions: dash.troxy.io\n'
|
|
104
104
|
+ 'Type /troxy off to pause routing.');
|
|
@@ -147,10 +147,11 @@ test('fetchSavings: sends the saved key and the session id; null without a key o
|
|
|
147
147
|
// (score 0.1) and the context is small, so the mod would route down to haiku.
|
|
148
148
|
function routingApi() {
|
|
149
149
|
const kv = new Map();
|
|
150
|
+
const sid = 'sess-' + Math.random().toString(36).slice(2, 10);
|
|
150
151
|
return {
|
|
151
152
|
state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
|
|
152
153
|
store: { get: async () => undefined },
|
|
153
|
-
session: { id: async () =>
|
|
154
|
+
session: { id: async () => sid, usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
|
|
154
155
|
plugin: { root: '/tmp/not-installed' },
|
|
155
156
|
model: { complete: async () => ({ isAnswered: true, text: '0.1' }) },
|
|
156
157
|
ui: { log: () => {} },
|
|
@@ -175,3 +176,28 @@ test('routeStep: a /model switch is honored for that turn, then routing resumes'
|
|
|
175
176
|
assert.equal(await runTurn($, sonnet), sonnet); // user picked sonnet: kept
|
|
176
177
|
assert.equal(await runTurn($, sonnet), haiku); // same choice again: routing resumes
|
|
177
178
|
});
|
|
179
|
+
|
|
180
|
+
test('pickModel: past the floor, stay on the warm model unless the prompt needs a stronger one', () => {
|
|
181
|
+
const { pickModel, MODELS } = _internal;
|
|
182
|
+
const big = 80000;
|
|
183
|
+
assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.haiku), MODELS.haiku); // easy: stay warm on haiku
|
|
184
|
+
assert.equal(pickModel(0.5, MODELS.opus, big, MODELS.haiku), MODELS.sonnet); // needs sonnet: step up
|
|
185
|
+
assert.equal(pickModel(0.1, MODELS.opus, big, MODELS.opus), MODELS.opus); // warm on opus: no switch down
|
|
186
|
+
assert.equal(pickModel(0.1, MODELS.opus, big, null), MODELS.opus); // cold or unknown: as before
|
|
187
|
+
assert.equal(pickModel(0.1, MODELS.opus, 1000, MODELS.opus), MODELS.haiku); // under the floor: route freely
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('routeStep: the session that crossed 50k on haiku stays on haiku (no cache rewrite)', async () => {
|
|
191
|
+
const $ = routingApi();
|
|
192
|
+
const { opus, haiku } = _internal.MODELS;
|
|
193
|
+
assert.equal(await runTurn($, opus), haiku);
|
|
194
|
+
$.session.usage = async () => ({ context: { tokens: 60000 }, rateLimits: [] });
|
|
195
|
+
assert.equal(await runTurn($, opus), haiku);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('pickModel: no score never causes a switch away from the warm model past the floor', () => {
|
|
199
|
+
const { pickModel, MODELS } = _internal;
|
|
200
|
+
assert.equal(pickModel(null, MODELS.opus, 80000, MODELS.haiku), MODELS.haiku);
|
|
201
|
+
assert.equal(pickModel(null, MODELS.opus, 1000, MODELS.haiku), MODELS.haiku); // small context too
|
|
202
|
+
assert.equal(pickModel(null, MODELS.opus, 1000, null), MODELS.opus); // nothing warm: as asked
|
|
203
|
+
});
|