troxy-cli 1.29.65 → 1.30.0

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/LICENSE ADDED
@@ -0,0 +1,17 @@
1
+ Copyright (c) 2026 Troxy. All rights reserved.
2
+
3
+ This software is proprietary. You may install and run it only to use the
4
+ Troxy service under your Troxy account, and only while your account is active.
5
+
6
+ You may not copy, modify, distribute, sublicense, sell, or create derivative
7
+ works from this software or any part of it, or use it to build a competing
8
+ product or service, without prior written permission from Troxy.
9
+
10
+ Versions published before this notice were released under the MIT License;
11
+ that license continues to apply to those versions only.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED. IN NO EVENT SHALL TROXY BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15
+ LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
16
+
17
+ Contact: support@troxy.io
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "troxy-cli",
3
- "version": "1.29.65",
3
+ "version": "1.30.0",
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": {
@@ -44,5 +44,5 @@
44
44
  "payments",
45
45
  "secrets"
46
46
  ],
47
- "license": "MIT"
47
+ "license": "SEE LICENSE IN LICENSE"
48
48
  }
package/src/init.js CHANGED
@@ -3,7 +3,7 @@ import os from 'os';
3
3
  import path from 'path';
4
4
  import readline from 'readline';
5
5
  import { execSync, execFileSync } from 'child_process';
6
- import { saveConfig } from './config.js';
6
+ import { loadConfig, saveConfig } from './config.js';
7
7
  import { evaluatePayment, api } from './api.js';
8
8
  import { INTERCEPTOR_PORT } from './daemon.js';
9
9
  import { ensureInterceptionCerts } from './tls-ca.js';
@@ -201,18 +201,7 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null,
201
201
  }
202
202
  }
203
203
  if (hasClaude) {
204
- // Terminal used to get base-URL substitution (maybeEnableClaudeCodeProxy)
205
- // here - found live 2026-09-07 that it silently bills the org's own
206
- // pay-as-you-go API key for every terminal call, since substituting
207
- // ANTHROPIC_AUTH_TOKEN with a Troxy token means no real Anthropic
208
- // credential ever reaches model-proxy. This now reuses the same
209
- // interception mechanism already live for Desktop (see
210
- // patchClaudeCodeInterception below) instead: terminal keeps its own
211
- // real login, Troxy watches the traffic on its way to Anthropic.
212
- // Desktop-only machines are unaffected by this call (hasDesktop is
213
- // not part of the gate here) - they still use the existing manual
214
- // `troxy proxy enable --experimental` path.
215
- await maybeEnableTroxyInterception(proxyOptIn, hasDesktop);
204
+ await useDirectAnthropic();
216
205
  }
217
206
  console.log('\n Restart your MCP client to activate Troxy.');
218
207
  }
@@ -589,8 +578,8 @@ export async function runInit({ key, name, proxy, concise } = {}) {
589
578
  // MOD installation is optional - init succeeds even if it fails
590
579
  }
591
580
 
592
- console.log('\n Your payments are now protected.');
593
- console.log(' Dashboard → https://dash.troxy.io');
581
+ console.log('\n Troxy is on. Restart Claude Code, then work as usual.');
582
+ console.log(' Your savings → https://dash.troxy.io (Usage and Savings)');
594
583
  console.log('\n For more information, visit https://docs.troxy.io\n');
595
584
  }
596
585
 
@@ -1036,67 +1025,22 @@ export function migrateBaseUrlProxyToInterception(configPath) {
1036
1025
  return true;
1037
1026
  }
1038
1027
 
1039
- // Terminal's counterpart to the desktop app's already-shipped
1040
- // interception path. See the call site above for why terminal moved off
1041
- // base-URL substitution. Same proxyOptIn semantics as the retired
1042
- // maybeEnableClaudeCodeProxy: true = enable without asking, null + TTY =
1043
- // ask interactively, otherwise skip (a scripted/CI init must never hang
1044
- // on a prompt).
1045
- async function maybeEnableTroxyInterception(proxyOptIn, hasDesktop, configPath = claudeCodeSettingsPath()) {
1046
- // Unconditional, regardless of what's decided below: a machine still on
1047
- // the old base-URL substitution keys is actively mischarging the org's
1048
- // API balance right now, and leaving that in place is strictly worse
1049
- // than falling back to no Troxy visibility for terminal at all.
1028
+ async function useDirectAnthropic(configPath = claudeCodeSettingsPath()) {
1050
1029
  const migrated = migrateBaseUrlProxyToInterception(configPath);
1051
1030
  if (migrated) {
1052
1031
  console.log(' • Removed the old Troxy proxy setup for terminal (was billing your org\'s API key instead of your subscription)');
1053
1032
  }
1054
-
1055
- if (interceptionIsConfigured(configPath)) return; // already set up correctly, nothing to ask
1056
-
1057
- // No auto-started background service on Windows yet (installService throws
1058
- // there), so pointing Claude Code at 127.0.0.1 would break every call
1059
- // after a reboot. Policies still work; the engine waits for Windows support.
1060
- if (process.platform === 'win32') {
1061
- console.log('\n The Troxy engine (lighter Claude Code requests) is not available on');
1062
- console.log(' Windows yet. Policies work. Mac and Linux get the engine today.');
1063
- return;
1064
- }
1065
-
1066
- let enable = proxyOptIn === true;
1067
- if (proxyOptIn === null && process.stdin.isTTY) {
1068
- console.log("\n Turn on the Troxy engine? Claude Code's requests go through Troxy and");
1069
- console.log(' are made lighter, so your usage goes further. Your normal Claude login');
1070
- console.log(' (subscription or API key) still');
1071
- console.log(' pays - Troxy never substitutes its own credential. Troxy does see your');
1072
- console.log(' real credential in transit, forwarded upstream to actually make the');
1073
- console.log(' call to Anthropic on your behalf.');
1074
- console.log(' How: Troxy generates a certificate authority ON THIS MACHINE');
1075
- console.log(' (~/.troxy/tls/). The private key never leaves this computer and is');
1076
- console.log(' never sent to Troxy. It is locked to api.anthropic.com only and is');
1077
- console.log(' NOT installed in your system keychain, only referenced by Claude Code.');
1078
- if (hasDesktop) {
1079
- console.log(' This also covers the Claude desktop app\'s Code tab (same setting).');
1080
- }
1081
- console.log(' Remove any time with: troxy proxy disable');
1082
- const answer = await prompt(' Enable? (y/N): ');
1083
- enable = /^y(es)?$/i.test(answer);
1084
- }
1085
- if (!enable) return;
1086
-
1087
- try {
1088
- const troxyDir = path.join(os.homedir(), '.troxy');
1089
- ensureInterceptionCerts(troxyDir, { hostname: os.hostname(), leafDnsNames: interceptHostsFor(enabledProviders()) });
1090
- patchClaudeCodeInterception(configPath);
1091
- console.log(` • Claude Code model proxy (cost optimization) ✓`);
1092
- const bound = await probePort(INTERCEPTOR_PORT);
1093
- if (!bound) {
1094
- console.log(` ⚠ Nothing is listening on 127.0.0.1:${INTERCEPTOR_PORT} yet - restart the`);
1095
- console.log(' background service (`troxy restart`) so Claude Code can connect.');
1096
- }
1097
- } catch (err) {
1098
- console.log(` • Claude Code model proxy ✗ (${err.message})`);
1033
+ // A Claude subscription's login token must never leave this machine: Anthropic's
1034
+ // Claude Code terms forbid a third party from intermediating it. So Claude Code
1035
+ // talks to Anthropic directly; the troxy-guard mod does the routing and reports
1036
+ // request metadata (reportUsage) for the dashboard. Older installs sent traffic
1037
+ // through the local interceptor to Troxy's proxy: undo that here, on init and on
1038
+ // every `troxy update`. API-key users can still opt in: `troxy proxy enable`.
1039
+ if (interceptionIsConfigured(configPath)) {
1040
+ unpatchClaudeCodeInterception(configPath);
1041
+ console.log(' • Claude Code now talks to Anthropic directly, your Claude login stays on this machine ✓');
1099
1042
  }
1043
+ saveConfig({ ...(loadConfig() || {}), reportUsage: true });
1100
1044
  }
1101
1045
 
1102
1046
  // Detects Claude Code the same way the daemon detects OpenClaw: by whether
@@ -361,6 +361,14 @@ async function _handleInterceptedRequest(req, res, { routeResolver, getTroxyKey,
361
361
  }
362
362
 
363
363
  const route = routeResolver(targetHost, req.method, req.url);
364
+ // A Claude subscription login (Authorization header) never goes to Troxy's
365
+ // proxy: Anthropic's terms forbid a third party from intermediating it. Only
366
+ // API-key requests (x-api-key) are relayed; everything else goes straight to
367
+ // Anthropic, and the troxy-guard mod reports its metadata instead.
368
+ if (route && req.headers.authorization) {
369
+ _relayToRealHost(targetHost, targetPort, req, res, body, log, lookup, ca);
370
+ return;
371
+ }
364
372
  if (route) {
365
373
  const troxyKey = getTroxyKey();
366
374
  if (troxyKey) {
@@ -192,25 +192,79 @@ function savingsText(s) {
192
192
 
193
193
  // This session's savings from the server, or null when there is no key, no session id or
194
194
  // the call fails (the band then keeps what it showed).
195
+ // The Troxy key and setup from ~/.troxy/config.json (TROXY_API_KEY wins for the key).
196
+ async function troxyConfig($) {
197
+ let cfg = {};
198
+ try {
199
+ const home = await $.env.get('HOME');
200
+ if (home) cfg = JSON.parse(await $.fs.read(`${home}/.troxy/config.json`)) || {};
201
+ } catch {}
202
+ return { ...cfg, apiKey: (await $.env.get('TROXY_API_KEY')) || cfg.apiKey };
203
+ }
204
+
205
+ async function troxyFetch($, url, init = {}) {
206
+ const controller = new AbortController();
207
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
208
+ try { return await $.http.fetch(url, { ...init, signal: controller.signal }); }
209
+ finally { clearTimeout(timer); }
210
+ }
211
+
195
212
  async function fetchSavings($) {
196
213
  const sid = await $.session.id();
197
214
  if (!/^[A-Za-z0-9-]{8,64}$/.test(String(sid))) return null;
198
- let key = await $.env.get('TROXY_API_KEY');
199
- if (!key) {
200
- const home = await $.env.get('HOME');
201
- if (!home) return null;
202
- key = JSON.parse(await $.fs.read(`${home}/.troxy/config.json`)).apiKey;
215
+ const { apiKey } = await troxyConfig($);
216
+ if (!apiKey) return null;
217
+ const res = await troxyFetch($, `https://api.troxy.io/mcp/session-savings?session_id=${sid}`, { headers: { 'x-troxy-key': apiKey } });
218
+ return res.ok ? JSON.parse(res.text) : null;
219
+ }
220
+
221
+ // Usage reporting. A Claude subscription's login token must never leave the Mac
222
+ // (Anthropic's Claude Code terms), so in that setup Claude Code talks to Anthropic
223
+ // directly and the mod reports each request's metadata instead: model, token counts,
224
+ // usage-bar readings, routing arm. Never prompt or response text. Only when
225
+ // `troxy init` set reportUsage: through the proxy, the proxy already logs each call.
226
+ const REPORT_URL = 'https://api.troxy.io/mcp/usage-report';
227
+ const REPORT_MAX_ROWS = 200; // the endpoint's cap per report
228
+ const REPORT_KEEP_ROWS = 1000; // kept for the next try while Troxy is unreachable
229
+ let pending = [];
230
+ let compacting = false;
231
+
232
+ function reportRow({ sid, usage, requested, arm, cold, score, compact, rateLimits }) {
233
+ const rl = {};
234
+ for (const w of rateLimits || []) {
235
+ if (!WINDOW_LABEL[w.kind]) continue;
236
+ const at = typeof w.resetsAt === 'number' ? new Date(w.resetsAt).toISOString() : w.resetsAt;
237
+ rl[w.kind] = { percent_used: w.percentUsed, resets_at: at };
203
238
  }
204
- if (!key) return null;
205
- const controller = new AbortController();
206
- const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
207
- try {
208
- const res = await $.http.fetch(`https://api.troxy.io/mcp/session-savings?session_id=${sid}`, {
209
- headers: { 'x-troxy-key': key }, signal: controller.signal,
239
+ return {
240
+ session_id: String(sid), model: usage.model, model_requested: requested || usage.model,
241
+ arm: arm || null, switch: arm ? (cold ? 'cold' : 'warm') : null, score: score == null ? null : score,
242
+ compact: Boolean(compact), input_tokens: usage.input_tokens, output_tokens: usage.output_tokens,
243
+ cache_read_tokens: usage.cache_read_input_tokens, cache_creation_tokens: usage.cache_creation_input_tokens,
244
+ rate_limits: rl,
245
+ };
246
+ }
247
+
248
+ async function noteStep($, result, meta) {
249
+ const usage = result && result.usage;
250
+ if (!usage || !usage.model) return;
251
+ let rateLimits = [];
252
+ try { rateLimits = (await $.session.usage()).rateLimits; } catch {}
253
+ pending.push(reportRow({ ...meta, sid: await $.session.id(), usage, compact: compacting, rateLimits }));
254
+ if (pending.length > REPORT_KEEP_ROWS) pending = pending.slice(-REPORT_KEEP_ROWS);
255
+ }
256
+
257
+ async function flushReport($) {
258
+ if (!pending.length) return;
259
+ const { apiKey, reportUsage } = await troxyConfig($);
260
+ if (!reportUsage || !apiKey) { pending = []; return; }
261
+ while (pending.length) {
262
+ const rows = pending.slice(0, REPORT_MAX_ROWS);
263
+ const res = await troxyFetch($, REPORT_URL, {
264
+ method: 'POST', headers: { 'content-type': 'application/json', 'x-troxy-key': apiKey }, body: JSON.stringify({ rows }),
210
265
  });
211
- return res.ok ? JSON.parse(res.text) : null;
212
- } finally {
213
- clearTimeout(timer);
266
+ if (!res.ok && res.status >= 500) return; // kept for the next turn
267
+ pending = pending.slice(rows.length); // sent, or refused as invalid: never resent
214
268
  }
215
269
  }
216
270
 
@@ -339,8 +393,9 @@ async function idleCompact($, { late = false } = {}) {
339
393
  const file = modRouteFile($.plugin.root, await $.session.id());
340
394
  if (file) { lastNote = null; await $.fs.write(file, JSON.stringify({ compact: true, ts: await $.clock.now() })); }
341
395
  let r;
396
+ compacting = true;
342
397
  try { r = await $.session.compact({ instructions: COMPACT_INSTRUCTIONS }); }
343
- finally { if (file) await $.fs.write(file, '{}'); }
398
+ finally { compacting = false; if (file) await $.fs.write(file, '{}'); }
344
399
  if (r.skip) return;
345
400
  const k = n => `${Math.round(n / 1000)}k`;
346
401
  await $.state.set(COMPACTED, { at: await $.clock.now(), text: r.tokensBefore && r.tokensAfter
@@ -451,11 +506,22 @@ function stepModel({ arm, requested, switched, cold, score, warm }) {
451
506
 
452
507
  async function* routeStep($, e, next) {
453
508
  // Main loop only: a subagent keeps whatever model it was given.
454
- if (e.agentId) return yield* next(e);
509
+ if (e.agentId) {
510
+ const result = yield* next(e);
511
+ try { await noteStep($, result, { requested: e.model, arm: await $.store.get(`arm:${await $.session.id()}`), cold: false }); } catch {}
512
+ return result;
513
+ }
455
514
  const sid = await $.session.id();
456
515
  const now = await $.clock.now();
457
- let { value: arm } = await $.state.get(ARM);
458
- if (!arm) { arm = Math.random() < MOD_HOLDOUT_FRACTION ? 'holdout' : 'treated'; await $.state.set(ARM, arm); }
516
+ // The arm is kept on disk per session: $.state is per process, so every `claude -p` step,
517
+ // resume and restart re-rolled the 10% holdout and pinned most sessions to the requested model.
518
+ let arm = null;
519
+ try { arm = await $.store.get(`arm:${sid}`); } catch {}
520
+ if (arm !== 'holdout' && arm !== 'treated') {
521
+ arm = Math.random() < MOD_HOLDOUT_FRACTION ? 'holdout' : 'treated';
522
+ try { await $.store.set(`arm:${sid}`, arm); } catch {}
523
+ }
524
+ await $.state.set(ARM, arm);
459
525
  const prev = lastAsked.get(sid);
460
526
  lastAsked.set(sid, e.model);
461
527
  const switched = Boolean(prev && prev !== e.model);
@@ -480,7 +546,9 @@ async function* routeStep($, e, next) {
480
546
  }
481
547
  try { await rememberAsked($, sid, want, model); } catch {} // a missed write only loses this on resume
482
548
  if (model !== want && turn) turn.ratio = PRICE[tierOf(want)] / PRICE[tierOf(model)];
483
- return yield* next(model === e.model ? e : { ...e, model });
549
+ const result = yield* next(model === e.model ? e : { ...e, model });
550
+ try { await noteStep($, result, { requested: want, arm, cold: isCold, score: decision ? decision.score : null }); } catch {} // a missed row only thins the dashboard
551
+ return result;
484
552
  }
485
553
 
486
554
  export function register(on) {
@@ -529,6 +597,7 @@ export function register(on) {
529
597
  // After the answer, so counting never delays a prompt.
530
598
  on('turn.complete', async ($, e, next) => {
531
599
  const r = await next(e);
600
+ try { await flushReport($); } catch {} // unsent rows wait for the next turn
532
601
  let savings = null;
533
602
  try {
534
603
  savings = await fetchSavings($);
@@ -554,4 +623,4 @@ export function register(on) {
554
623
  }
555
624
 
556
625
  // Exported for tests only - not part of the mod's runtime contract.
557
- export const _internal = { askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
626
+ export const _internal = { reportRow, noteStep, flushReport, pendingRows: () => pending, troxyConfig, askedFor, rememberAsked, asked, armCompact, cancelCompact, toDomainAndFields, guardBash, failClosed, parseScore, asRequest, pickModel, troxyCommand, modRouteFile, estimateTokens, MODELS, savedLine, sessionLine, addTurn, withKept, statusText, compactPlan, savingsText, fetchSavings, scorePrompt, routeStep, stepModel, lastSent };
package/src/proxy.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  import { ensureInterceptionCerts, certIsValid, certExpiresWithin } from './tls-ca.js';
23
23
  import { enabledProviders, interceptHostsFor } from './providers.js';
24
24
  import { INTERCEPTOR_PORT } from './daemon.js';
25
+ import { loadConfig, saveConfig } from './config.js';
25
26
 
26
27
  const TROXY_DIR = path.join(os.homedir(), '.troxy');
27
28
 
@@ -115,9 +116,10 @@ export async function runProxyEnable(flags = {}) {
115
116
  any other site - and it is never installed in your system keychain, only
116
117
  referenced by Claude Code itself.
117
118
 
118
- \`troxy init\` already offers this for terminal Claude Code automatically.
119
- This command is for a desktop-only machine (no terminal \`claude\`), or to
120
- reconfigure by hand. --experimental is required as an explicit
119
+ For Claude API keys only. A Claude subscription login (Pro, Max) is never
120
+ sent to Troxy: Anthropic's terms forbid it, so those requests always go
121
+ straight to Anthropic, and \`troxy init\` sets that up by default. With a
122
+ subscription, leave this off. --experimental is required as an explicit
121
123
  acknowledgment.
122
124
 
123
125
  Remove any time with: troxy proxy disable
@@ -157,6 +159,8 @@ export async function runProxyEnable(flags = {}) {
157
159
  process.stdout.write(' Writing ~/.claude/settings.json... ');
158
160
  try {
159
161
  patchClaudeCodeInterception(settingsPath);
162
+ // The proxy logs these calls itself; the mod reporting them too would count each twice.
163
+ saveConfig({ ...(loadConfig() || {}), reportUsage: false });
160
164
  console.log('✓');
161
165
  } catch (err) {
162
166
  console.log('✗');
@@ -190,6 +194,7 @@ export function runProxyDisable() {
190
194
  process.stdout.write('\n Removing HTTPS_PROXY / NODE_EXTRA_CA_CERTS from settings.json... ');
191
195
  try {
192
196
  unpatchClaudeCodeInterception(settingsPath);
197
+ saveConfig({ ...(loadConfig() || {}), reportUsage: true }); // the mod reports again
193
198
  console.log('✓\n');
194
199
  } catch (err) {
195
200
  console.log('✗');
@@ -383,7 +383,7 @@ describe('createInterceptor - integration, real sockets', () => {
383
383
  const result = await httpsOverConnectedSocket(rawSocket, {
384
384
  servername: 'api.anthropic.com', ca: [ca.certPem],
385
385
  method: 'POST', path: '/v1/messages?beta=true',
386
- headers: { authorization: 'Bearer sk-ant-oat01-realtoken', 'content-type': 'application/json' },
386
+ headers: { 'x-api-key': 'sk-ant-api03-realkey', 'content-type': 'application/json' },
387
387
  body: '{"model":"claude-sonnet-5"}',
388
388
  });
389
389
 
@@ -398,7 +398,7 @@ describe('createInterceptor - integration, real sockets', () => {
398
398
  // ran" without checking the dashboard for a decision_source row.
399
399
  `expected a "routed ... to troxy" confirmation log line, got: ${JSON.stringify(logLines)}`,
400
400
  );
401
- assert.equal(troxyRequests[0].headers['x-troxy-upstream-authorization'], 'Bearer sk-ant-oat01-realtoken');
401
+ assert.equal(troxyRequests[0].headers['x-api-key'], 'sk-ant-api03-realkey');
402
402
  assert.equal(troxyRequests[0].body, '{"model":"claude-sonnet-5"}');
403
403
  });
404
404
 
@@ -440,6 +440,47 @@ describe('createInterceptor - integration, real sockets', () => {
440
440
  'the client\'s own credential must reach the real host unmodified');
441
441
  });
442
442
 
443
+ it('a Claude subscription login (Authorization) on a routed path never reaches Troxy - ' +
444
+ 'it goes straight to the real host, untouched (Anthropic terms: no third party ' +
445
+ 'may intermediate a subscription token)', async () => {
446
+ const troxyHits = [];
447
+ const troxyServer = await startStubHttpServer((req, res) => { troxyHits.push(req.url); res.end('{}'); });
448
+ openServers.push(troxyServer);
449
+ const ca = generateCA('test-host');
450
+ const leaf = generateLeaf(ca.certPem, ca.keyPem, ['api.anthropic.com']);
451
+
452
+ const realRequests = [];
453
+ const realServer = await startStubTlsServer(leaf.certPem, leaf.keyPem, (req, res) => {
454
+ realRequests.push({ url: req.url, headers: req.headers });
455
+ res.writeHead(200, {});
456
+ res.end('real-anthropic-response');
457
+ });
458
+ openServers.push(realServer);
459
+
460
+ const interceptor = createInterceptor({
461
+ interceptHosts: ['api.anthropic.com'],
462
+ routeResolver: () => ({ upstream: `http://127.0.0.1:${troxyServer.address().port}/v1/messages` }),
463
+ getTroxyKey: () => 'txy-test-key',
464
+ getCerts: () => ({ leafCertPem: leaf.certPem, leafKeyPem: leaf.keyPem }),
465
+ lookup: LOOPBACK_LOOKUP,
466
+ ca: [ca.certPem], // the interceptor's OWN outbound trust for reaching the stub "real host"
467
+ });
468
+ await new Promise(r => interceptor.listen(0, '127.0.0.1', r));
469
+ openServers.push(interceptor);
470
+
471
+ const rawSocket = await connectThroughProxy(interceptor.address().port, 'api.anthropic.com', realServer.address().port);
472
+ const result = await httpsOverConnectedSocket(rawSocket, {
473
+ servername: 'api.anthropic.com', ca: [ca.certPem],
474
+ method: 'GET', path: '/api/oauth/account/settings',
475
+ headers: { authorization: 'Bearer sk-ant-oat01-realtoken' },
476
+ });
477
+
478
+ assert.equal(result.statusCode, 200);
479
+ assert.equal(result.body, 'real-anthropic-response');
480
+ assert.equal(realRequests[0].headers.authorization, 'Bearer sk-ant-oat01-realtoken',
481
+ 'the client\'s own credential must reach the real host unmodified');
482
+ assert.equal(troxyHits.length, 0, 'a subscription login must never be sent to Troxy');
483
+ });
443
484
  it('a host NOT on the intercept allowlist is a blind tunnel - the client\'s TLS ' +
444
485
  'terminates at the REAL destination\'s own certificate, never at Troxy\'s', async () => {
445
486
  // A completely unrelated CA/cert pair, standing in for a real site the
@@ -512,7 +553,7 @@ describe('createInterceptor - integration, real sockets', () => {
512
553
  const result = await httpsOverConnectedSocket(rawSocket, {
513
554
  servername: 'api.anthropic.com', ca: [ca.certPem],
514
555
  method: 'POST', path: '/v1/messages',
515
- headers: { authorization: 'Bearer sk-ant-oat01-realtoken' }, body: '{}',
556
+ headers: { 'x-api-key': 'sk-ant-api03-realkey' }, body: '{}',
516
557
  });
517
558
 
518
559
  assert.equal(result.statusCode, 200);
@@ -553,7 +594,7 @@ describe('createInterceptor - integration, real sockets', () => {
553
594
  const result = await httpsOverConnectedSocket(rawSocket, {
554
595
  servername: 'api.anthropic.com', ca: [ca.certPem],
555
596
  method: 'POST', path: '/v1/messages',
556
- headers: { authorization: 'Bearer sk-ant-oat01-realtoken' }, body: '{}',
597
+ headers: { 'x-api-key': 'sk-ant-api03-realkey' }, body: '{}',
557
598
  });
558
599
 
559
600
  assert.equal(result.statusCode, 200);
@@ -609,7 +650,7 @@ describe('createInterceptor - integration, real sockets', () => {
609
650
  const result = await httpsOverConnectedSocket(rawSocket, {
610
651
  servername: 'api.anthropic.com', ca: [ca.certPem],
611
652
  method: 'POST', path: '/v1/messages',
612
- headers: { authorization: 'Bearer x' }, body: '{}',
653
+ headers: { 'x-api-key': 'sk-ant-api03-x' }, body: '{}',
613
654
  });
614
655
  assert.equal(result.statusCode, 200);
615
656
  }
@@ -670,7 +711,7 @@ describe('createInterceptor - integration, real sockets', () => {
670
711
  const result = await httpsOverConnectedSocket(rawSocket, {
671
712
  servername: 'api.anthropic.com', ca: [ca.certPem],
672
713
  method: 'POST', path: '/v1/messages',
673
- headers: { authorization: 'Bearer sk-ant-oat01-realtoken' }, body: '{}',
714
+ headers: { 'x-api-key': 'sk-ant-api03-realkey' }, body: '{}',
674
715
  });
675
716
 
676
717
  assert.equal(result.statusCode, 200);
@@ -161,7 +161,7 @@ function routingApi() {
161
161
  const sid = 'sess-' + Math.random().toString(36).slice(2, 10);
162
162
  return {
163
163
  state: { get: async (k) => ({ value: kv.get(k.key) }), set: async (k, v) => kv.set(k.key, v) },
164
- store: { get: async () => undefined },
164
+ store: { get: async (k) => (String(k).startsWith('arm:') ? 'treated' : undefined) },
165
165
  session: { id: async () => sid, usage: async () => ({ context: { tokens: 1000 }, rateLimits: [] }) },
166
166
  plugin: { root: '/tmp/not-installed' },
167
167
  model: { complete: async () => ({ isAnswered: true, text: '0.1' }) },
@@ -196,6 +196,7 @@ test('routeStep: a new process (claude -p step, resume, restart) keeps the warm
196
196
  store: { get: async (k) => disk.get(k), set: async (k, v) => disk.set(k, v) },
197
197
  clock: { now: async () => now },
198
198
  model: { complete: async () => ({ isAnswered: true, text: score }) } };
199
+ disk.set(`arm:${await $.session.id()}`, 'treated'); // the arm is rolled once per session; pin it
199
200
  assert.equal(await runTurn($, M.opus), M.sonnet); // cold start, easy prompt: sonnet
200
201
  lastSent.clear(); score = '0.95'; // new process, hard prompt
201
202
  now += 10 * 60 * 1000;
@@ -204,6 +205,23 @@ test('routeStep: a new process (claude -p step, resume, restart) keeps the warm
204
205
  assert.equal(await runTurn($, M.opus), M.opus); // 55+ min idle: cold, re-picked
205
206
  });
206
207
 
208
+ test('routeStep: the holdout arm is rolled once per session, not once per process', async () => {
209
+ const { MODELS: M, lastSent } = _internal;
210
+ const disk = new Map();
211
+ const mk = () => { const a = routingApi(); return { ...a,
212
+ state: { get: async () => ({ value: undefined }), set: async () => {} }, // per-process state starts empty
213
+ session: { ...a.session, id: async () => 'one-session' },
214
+ store: { get: async (k) => disk.get(k), set: async (k, v) => disk.set(k, v) } }; };
215
+ const real = Math.random;
216
+ try {
217
+ Math.random = () => 0.5; // would be treated
218
+ assert.equal(await runTurn(mk(), M.opus), M.sonnet);
219
+ lastSent.clear();
220
+ Math.random = () => 0.01; // a re-roll would now be a holdout
221
+ assert.equal(await runTurn(mk(), M.opus), M.sonnet); // new process: arm read from disk, still treated
222
+ } finally { Math.random = real; }
223
+ });
224
+
207
225
  test('stepModel: decide only at cold moments, hold in between, holdout never routes', () => {
208
226
  const { stepModel, MODELS: M } = _internal;
209
227
  const base = { arm: 'treated', requested: M.opus, switched: false, warm: M.haiku };
@@ -328,3 +346,59 @@ test('askedFor: a resumed session that reopens on the routed model is mapped bac
328
346
  assert.equal(await askedFor($, 's1', sonnet, true), sonnet); // the user's own /model wins
329
347
  assert.equal(await askedFor($, 's2', haiku, false), haiku); // never routed: Haiku is the pick
330
348
  });
349
+
350
+ test('usage report: each request is queued with model, tokens, bar and arm, then sent at turn end', async () => {
351
+ const { MODELS: M, routeStep, scorePrompt, flushReport, pendingRows } = _internal;
352
+ const posts = [];
353
+ const $ = { ...routingApi(),
354
+ session: { id: async () => 'report-sess-1', usage: async () => ({ context: { tokens: 1000 },
355
+ rateLimits: [{ kind: 'five_hour', percentUsed: 42, resetsAt: '2026-10-20T10:00:00Z' }, { kind: 'other', percentUsed: 1 }] }) },
356
+ env: { get: async (k) => (k === 'HOME' ? '/home/x' : undefined) },
357
+ fs: { write: async () => {}, read: async () => JSON.stringify({ apiKey: 'txy-test', reportUsage: true }) },
358
+ http: { fetch: async (url, init) => { posts.push({ url, init }); return { ok: true, status: 200 }; } } };
359
+ await scorePrompt($, { text: 'reply only ok', origin: { kind: 'composer' } }, async (e) => e);
360
+ const usage = { model: M.sonnet, input_tokens: 5, output_tokens: 7, cache_read_input_tokens: 900, cache_creation_input_tokens: 30 };
361
+ const gen = routeStep($, { model: M.opus }, async function* () { return { usage }; });
362
+ let step; do { step = await gen.next(); } while (!step.done);
363
+ assert.deepEqual(step.value, { usage }); // the result is passed on unchanged
364
+ const row = pendingRows().at(-1);
365
+ assert.equal(row.model, M.sonnet); assert.equal(row.model_requested, M.opus);
366
+ assert.equal(row.arm, 'treated'); assert.equal(row.switch, 'cold'); assert.equal(row.cache_read_tokens, 900);
367
+ assert.deepEqual(row.rate_limits, { five_hour: { percent_used: 42, resets_at: '2026-10-20T10:00:00Z' } });
368
+ assert.equal(JSON.stringify(row).includes('reply only ok'), false); // never prompt text
369
+ await flushReport($);
370
+ assert.equal(posts.length, 1);
371
+ assert.equal(posts[0].url, 'https://api.troxy.io/mcp/usage-report');
372
+ assert.equal(posts[0].init.headers['x-troxy-key'], 'txy-test');
373
+ assert.equal(JSON.parse(posts[0].init.body).rows.at(-1).session_id, 'report-sess-1');
374
+ assert.equal(pendingRows().length, 0);
375
+ });
376
+
377
+ test('usage report: nothing is sent unless init turned reporting on (the proxy logs those calls)', async () => {
378
+ const { noteStep, flushReport, pendingRows } = _internal;
379
+ const posts = [];
380
+ const $ = { ...routingApi(),
381
+ env: { get: async () => '/home/x' },
382
+ fs: { read: async () => JSON.stringify({ apiKey: 'txy-test' }) },
383
+ http: { fetch: async () => { posts.push(1); return { ok: true }; } } };
384
+ await noteStep($, { usage: { model: 'claude-sonnet-5-5', input_tokens: 1 } }, { requested: 'claude-sonnet-5-5' });
385
+ assert.equal(pendingRows().length, 1);
386
+ await flushReport($);
387
+ assert.equal(posts.length, 0);
388
+ assert.equal(pendingRows().length, 0);
389
+ });
390
+
391
+ test('usage report: a server error keeps the rows for the next turn', async () => {
392
+ const { noteStep, flushReport, pendingRows } = _internal;
393
+ let status = 503;
394
+ const $ = { ...routingApi(),
395
+ env: { get: async () => '/home/x' },
396
+ fs: { read: async () => JSON.stringify({ apiKey: 'txy-test', reportUsage: true }) },
397
+ http: { fetch: async () => ({ ok: status < 400, status }) } };
398
+ await noteStep($, { usage: { model: 'claude-sonnet-5-5', input_tokens: 1 } }, {});
399
+ await flushReport($);
400
+ assert.equal(pendingRows().length, 1);
401
+ status = 200;
402
+ await flushReport($);
403
+ assert.equal(pendingRows().length, 0);
404
+ });
@@ -58,23 +58,22 @@ describe('reprovisionKeyConsumers: desktop detection gate', () => {
58
58
  assert.equal(_guardCondition('patchClaudeCodeConfig(claudeCodeConfigPath(), key)'), 'hasClaude');
59
59
  });
60
60
 
61
- it('calls maybeEnableTroxyInterception (not the old base-URL substitution) gated on hasClaude alone', () => {
62
- assert.equal(_guardCondition('await maybeEnableTroxyInterception(proxyOptIn, hasDesktop);'), 'hasClaude');
61
+ it('calls useDirectAnthropic (Claude Code talks to Anthropic directly) gated on hasClaude alone', () => {
62
+ assert.equal(_guardCondition('await useDirectAnthropic();'), 'hasClaude');
63
63
  });
64
64
 
65
65
  it('no longer calls maybeEnableClaudeCodeProxy from reprovisionKeyConsumers - terminal fully switched to interception', () => {
66
66
  assert.ok(!initSrc.includes('await maybeEnableClaudeCodeProxy'));
67
67
  });
68
68
 
69
- it('maybeEnableTroxyInterception runs migration before the enable/proxyOptIn decision, so it always fires', () => {
70
- const fnStart = initSrc.indexOf('async function maybeEnableTroxyInterception');
71
- assert.ok(fnStart !== -1, 'maybeEnableTroxyInterception not found');
72
- const fnBody = initSrc.slice(fnStart, fnStart + 2000);
73
- const migrateIdx = fnBody.indexOf('migrateBaseUrlProxyToInterception(');
74
- const optInIdx = fnBody.indexOf('proxyOptIn === true');
75
- assert.ok(migrateIdx !== -1, 'migrateBaseUrlProxyToInterception call not found in function body');
76
- assert.ok(optInIdx !== -1, 'proxyOptIn check not found in function body');
77
- assert.ok(migrateIdx < optInIdx, 'migration must run unconditionally, before the enable/proxyOptIn decision');
69
+ it('useDirectAnthropic migrates old setups, removes interception and turns mod reporting on - never adds interception', () => {
70
+ const fnStart = initSrc.indexOf('async function useDirectAnthropic');
71
+ assert.ok(fnStart !== -1, 'useDirectAnthropic not found');
72
+ const fnBody = initSrc.slice(fnStart, initSrc.indexOf('\n}\n', fnStart));
73
+ assert.ok(fnBody.includes('migrateBaseUrlProxyToInterception('));
74
+ assert.ok(fnBody.includes('unpatchClaudeCodeInterception(configPath)'));
75
+ assert.ok(fnBody.includes('reportUsage: true'));
76
+ assert.ok(!/[^n]patchClaudeCodeInterception\(/.test(fnBody), 'must never route Claude Code through the interceptor');
78
77
  });
79
78
  });
80
79