troxy-cli 1.29.0 → 1.29.2

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.0",
3
+ "version": "1.29.2",
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": {
@@ -42,6 +42,11 @@ export function extractUsage(entry) {
42
42
  // dollar math server-side from the model id; this file only sums what
43
43
  // the transcript says was used.
44
44
  tokens: inputTokens + outputTokens + cacheCreation + cacheRead,
45
+ // Reply-only portion of the total above - sent alongside actual_tokens,
46
+ // never in place of it (server still bills off the combined total; this
47
+ // is purely so the activity log can show "X tokens (Y reply)" instead
48
+ // of one opaque combined number - Gilad, 2026-09-04).
49
+ outputTokens,
45
50
  // Lives on the transcript ENTRY itself, not inside message.usage -
46
51
  // confirmed live 2026-09-03 against a real session file (values seen:
47
52
  // 'high', 'max'). Whatever string Claude Code actually used, passed
@@ -128,7 +133,11 @@ export function usageForCurrentTurn(transcriptPath) {
128
133
  if (seen.size === 0) return null;
129
134
 
130
135
  let totalTokens = 0;
131
- for (const u of seen.values()) totalTokens += u.tokens;
136
+ let totalOutputTokens = 0;
137
+ for (const u of seen.values()) {
138
+ totalTokens += u.tokens;
139
+ totalOutputTokens += u.outputTokens || 0;
140
+ }
132
141
 
133
142
  // turn_key: dedups a hook that fires twice for the same turn (a retry,
134
143
  // a Claude Code internal replay) against a repeat POST for the same
@@ -151,8 +160,8 @@ export function usageForCurrentTurn(transcriptPath) {
151
160
  const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
152
161
 
153
162
  return {
154
- tokens: totalTokens, model: lastModel, effort: lastEffort, toolUseBlocksSoFar,
155
- turnKey, contentExcerpt,
163
+ tokens: totalTokens, outputTokens: totalOutputTokens, model: lastModel,
164
+ effort: lastEffort, toolUseBlocksSoFar, turnKey, contentExcerpt,
156
165
  };
157
166
  }
158
167
 
@@ -202,6 +211,10 @@ export async function runHookReport() {
202
211
  const turnKey = sessionId ? `${sessionId}:${usage.turnKey}` : null;
203
212
 
204
213
  const body = { model: usage.model, actual_tokens: usage.tokens, turn_key: turnKey };
214
+ // Additive only - the backend already treats a missing/invalid value as
215
+ // "no breakdown available" and falls back to the plain total, so it's
216
+ // safe to just omit this rather than validate it client-side too.
217
+ if (usage.outputTokens > 0) body.actual_output_tokens = usage.outputTokens;
205
218
  // content_excerpt is optional and additive - the backend classifies it
206
219
  // if present, samples/rate-limits on its own, and silently skips when
207
220
  // it isn't. Sending it is not required for the usage report itself to
package/src/init.js CHANGED
@@ -100,7 +100,7 @@ export async function reprovisionKeyConsumers(key, agentName, proxyOptIn = null)
100
100
  } catch (err) {
101
101
  console.log(` • Claude Code usage capture ✗ (${err.message})`);
102
102
  }
103
- await maybeEnableClaudeCodeProxy(proxyOptIn);
103
+ await maybeEnableClaudeCodeProxy(proxyOptIn, key);
104
104
  }
105
105
  console.log('\n Restart your MCP client to activate Troxy.');
106
106
  }
@@ -583,12 +583,24 @@ export function troxyModelProxyBaseUrl() {
583
583
  // 2. Remote Control is disabled while the base URL points elsewhere
584
584
  // (Claude Code v2.1.196+) - nothing to configure around this one, it's
585
585
  // just true while the proxy is active, hence the up-front warning.
586
- export function patchClaudeCodeProxy(configPath, baseUrl = troxyModelProxyBaseUrl()) {
586
+ export function patchClaudeCodeProxy(configPath, troxyKey, baseUrl = troxyModelProxyBaseUrl()) {
587
587
  let config = {};
588
588
  try { config = JSON.parse(fs.readFileSync(configPath, 'utf8')); } catch {}
589
589
  if (!config.env) config.env = {};
590
590
  config.env.ANTHROPIC_BASE_URL = baseUrl;
591
591
  config.env.ENABLE_TOOL_SEARCH = 'true';
592
+ // Found live 2026-09-04: without this, Claude Code still authenticates to
593
+ // whatever base_url it's pointed at using its own Anthropic credential (an
594
+ // API key sent as x-api-key, or a Claude.ai OAuth session token sent as
595
+ // Authorization) - neither is a Troxy key, so the model-proxy's
596
+ // authenticate_api_key(troxy_key) check (reads the Authorization header)
597
+ // rejected every real request as "invalid or revoked Troxy API key",
598
+ // silently making the whole proxy path a no-op regardless of base URL.
599
+ // ANTHROPIC_AUTH_TOKEN is the one Claude Code env var that maps directly
600
+ // onto Authorization: Bearer <value> (same docs page as above) - setting
601
+ // it to the Troxy key is what the model-proxy actually needs to identify
602
+ // the caller.
603
+ if (troxyKey) config.env.ANTHROPIC_AUTH_TOKEN = troxyKey;
592
604
 
593
605
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
594
606
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
@@ -601,7 +613,7 @@ export function patchClaudeCodeProxy(configPath, baseUrl = troxyModelProxyBaseUr
601
613
  // proxyOptIn === null -> not passed; ask interactively if there's a TTY,
602
614
  // otherwise skip (a scripted/CI init must never hang
603
615
  // on a prompt, and silence must never mean yes)
604
- async function maybeEnableClaudeCodeProxy(proxyOptIn) {
616
+ async function maybeEnableClaudeCodeProxy(proxyOptIn, key) {
605
617
  let enable = proxyOptIn === true;
606
618
  if (proxyOptIn === null && process.stdin.isTTY) {
607
619
  console.log("\n Route Claude Code's model calls through Troxy for automatic cost");
@@ -609,15 +621,14 @@ async function maybeEnableClaudeCodeProxy(proxyOptIn) {
609
621
  console.log(' opportunity, not just suggest one.');
610
622
  console.log(" Trade-off: while this is on, Claude Code's Remote Control feature is");
611
623
  console.log(' disabled (Anthropic disables it whenever the API base URL points');
612
- console.log(' anywhere other than api.anthropic.com). Also, model policies (e.g. a');
613
- console.log(' BLOCK rule on a specific model) are not enforced on this path yet -');
614
- console.log(' only the cost-saving model swap is live.');
624
+ console.log(' anywhere other than api.anthropic.com). Model policies (e.g. a BLOCK');
625
+ console.log(' rule on a specific model) ARE enforced on this path.');
615
626
  const answer = await prompt(' Enable? (y/N): ');
616
627
  enable = /^y(es)?$/i.test(answer);
617
628
  }
618
629
  if (!enable) return;
619
630
  try {
620
- patchClaudeCodeProxy(claudeCodeSettingsPath());
631
+ patchClaudeCodeProxy(claudeCodeSettingsPath(), key);
621
632
  console.log(` • Claude Code model proxy (cost optimization) ✓`);
622
633
  } catch (err) {
623
634
  console.log(` • Claude Code model proxy ✗ (${err.message})`);
@@ -1,11 +1,12 @@
1
1
  // Checklist #17 ("one-command proxy setup"): patchClaudeCodeProxy writes
2
- // ANTHROPIC_BASE_URL + ENABLE_TOOL_SEARCH into Claude Code's global
3
- // settings.json `env` block, so its own model calls route through
4
- // proxy.troxy.io instead of straight to Anthropic. This is opt-in only
5
- // (Gilad, 2026-08-29) - the prompt/flag gating that decides WHETHER to call
6
- // this lives in reprovisionKeyConsumers/maybeEnableClaudeCodeProxy, not
7
- // tested here; this file covers what actually gets written once it's
8
- // called, same split as claude-code-hook.test.js does for the hooks patch.
2
+ // ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN + ENABLE_TOOL_SEARCH into Claude
3
+ // Code's global settings.json `env` block, so its own model calls route
4
+ // through proxy.troxy.io instead of straight to Anthropic, AND authenticate
5
+ // as this Troxy user once they get there. This is opt-in only (Gilad,
6
+ // 2026-08-29) - the prompt/flag gating that decides WHETHER to call this
7
+ // lives in reprovisionKeyConsumers/maybeEnableClaudeCodeProxy, not tested
8
+ // here; this file covers what actually gets written once it's called, same
9
+ // split as claude-code-hook.test.js does for the hooks patch.
9
10
 
10
11
  import { describe, it, beforeEach, afterEach } from 'node:test';
11
12
  import assert from 'node:assert/strict';
@@ -22,28 +23,45 @@ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
22
23
  describe('patchClaudeCodeProxy', () => {
23
24
  const configPath = () => path.join(dir, 'settings.json');
24
25
  const read = () => JSON.parse(fs.readFileSync(configPath(), 'utf8'));
26
+ const KEY = 'txy-test-key-123';
25
27
 
26
28
  it('creates the env block with ANTHROPIC_BASE_URL when the file does not exist', () => {
27
- patchClaudeCodeProxy(configPath());
29
+ patchClaudeCodeProxy(configPath(), KEY);
28
30
  const cfg = read();
29
31
  assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
30
32
  });
31
33
 
32
34
  it('sets ENABLE_TOOL_SEARCH so the model-proxy\'s forwarded tool_reference blocks still work', () => {
33
- patchClaudeCodeProxy(configPath());
35
+ patchClaudeCodeProxy(configPath(), KEY);
34
36
  const cfg = read();
35
37
  assert.equal(cfg.env.ENABLE_TOOL_SEARCH, 'true');
36
38
  });
37
39
 
40
+ it('sets ANTHROPIC_AUTH_TOKEN to the Troxy key - found live 2026-09-04: without this, ' +
41
+ 'Claude Code sends its own Anthropic credential as Authorization, and the model-proxy ' +
42
+ 'rejects every real request as an invalid Troxy key, making the whole proxy path a no-op',
43
+ () => {
44
+ patchClaudeCodeProxy(configPath(), KEY);
45
+ const cfg = read();
46
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, KEY);
47
+ });
48
+
49
+ it('does not write ANTHROPIC_AUTH_TOKEN at all when no key is given, rather than writing "undefined"', () => {
50
+ patchClaudeCodeProxy(configPath());
51
+ const cfg = read();
52
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, undefined);
53
+ });
54
+
38
55
  it('accepts a custom base URL for testing/overrides instead of hardcoding proxy.troxy.io', () => {
39
- patchClaudeCodeProxy(configPath(), 'https://staging.example.com');
56
+ patchClaudeCodeProxy(configPath(), KEY, 'https://staging.example.com');
40
57
  const cfg = read();
41
58
  assert.equal(cfg.env.ANTHROPIC_BASE_URL, 'https://staging.example.com');
59
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, KEY);
42
60
  });
43
61
 
44
62
  it('preserves unrelated existing env keys instead of replacing the whole block', () => {
45
63
  fs.writeFileSync(configPath(), JSON.stringify({ env: { SOME_OTHER_VAR: 'keep-me' } }));
46
- patchClaudeCodeProxy(configPath());
64
+ patchClaudeCodeProxy(configPath(), KEY);
47
65
  const cfg = read();
48
66
  assert.equal(cfg.env.SOME_OTHER_VAR, 'keep-me');
49
67
  assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
@@ -51,21 +69,22 @@ describe('patchClaudeCodeProxy', () => {
51
69
 
52
70
  it('preserves unrelated top-level keys (e.g. hooks already written by patchClaudeCodeHooks)', () => {
53
71
  fs.writeFileSync(configPath(), JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'x' }] }] } }));
54
- patchClaudeCodeProxy(configPath());
72
+ patchClaudeCodeProxy(configPath(), KEY);
55
73
  const cfg = read();
56
74
  assert.ok(cfg.hooks.Stop.length === 1, 'existing hooks block was dropped');
57
75
  assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
58
76
  });
59
77
 
60
78
  it('re-running is idempotent - overwrites rather than duplicating or erroring', () => {
61
- patchClaudeCodeProxy(configPath());
62
- patchClaudeCodeProxy(configPath());
79
+ patchClaudeCodeProxy(configPath(), KEY);
80
+ patchClaudeCodeProxy(configPath(), KEY);
63
81
  const cfg = read();
64
82
  assert.equal(cfg.env.ANTHROPIC_BASE_URL, troxyModelProxyBaseUrl());
83
+ assert.equal(cfg.env.ANTHROPIC_AUTH_TOKEN, KEY);
65
84
  });
66
85
 
67
86
  it('does not throw when the existing file is corrupted JSON', () => {
68
87
  fs.writeFileSync(configPath(), '{ not json');
69
- assert.doesNotThrow(() => patchClaudeCodeProxy(configPath()));
88
+ assert.doesNotThrow(() => patchClaudeCodeProxy(configPath(), KEY));
70
89
  });
71
90
  });
@@ -59,8 +59,9 @@ describe('extractUsage', () => {
59
59
  },
60
60
  };
61
61
  const result = extractUsage(entry);
62
- assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'tokens']);
62
+ assert.deepEqual(Object.keys(result).sort(), ['effort', 'messageId', 'model', 'outputTokens', 'tokens']);
63
63
  assert.equal(result.effort, 'high');
64
+ assert.equal(result.outputTokens, 5);
64
65
  assert.ok(!JSON.stringify(result).includes('actual conversation'));
65
66
  });
66
67
 
@@ -108,6 +109,20 @@ describe('usageForCurrentTurn', () => {
108
109
  assert.equal(result.tokens, 10 + 20 + 5 + 8);
109
110
  });
110
111
 
112
+ it('sums outputTokens (reply-only) alongside tokens, same dedup-by-message-id rule', () => {
113
+ // Same fixture as the two-real-model-calls test above - outputTokens must
114
+ // dedup identically (20 + 8, not 20+20+8) since it comes off the same
115
+ // per-message.id `seen` map as the combined total.
116
+ writeLines([
117
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
118
+ assistantLine('msg_1', { input_tokens: 10, output_tokens: 20 }),
119
+ assistantLine('msg_1', { input_tokens: 10, output_tokens: 20 }),
120
+ assistantLine('msg_2', { input_tokens: 5, output_tokens: 8 }),
121
+ ]);
122
+ const result = usageForCurrentTurn(transcriptPath());
123
+ assert.equal(result.outputTokens, 20 + 8, 'reply-only total did not dedup by message.id like tokens does');
124
+ });
125
+
111
126
  it('only looks back to the last user-authored line, not the whole session', () => {
112
127
  writeLines([
113
128
  { type: 'user', message: { content: [{ type: 'text', text: 'first turn' }] } },