troxy-cli 1.28.0 → 1.29.1

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.28.0",
3
+ "version": "1.29.1",
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": {
@@ -102,6 +102,15 @@ export function usageForCurrentTurn(transcriptPath) {
102
102
  const seen = new Map(); // messageId -> {model, tokens, effort}
103
103
  let lastModel = null;
104
104
  let lastEffort = null;
105
+ // How many tool_use content blocks this turn has produced so far -
106
+ // Claude Code writes one JSONL line per content block (see the big
107
+ // comment above), so this is a real count, not a guess, of tool calls
108
+ // already made this turn, before whichever one just triggered this hook.
109
+ // Only the block's `type` tag is read here, a category label
110
+ // ("tool_use"/"thinking"/"text"), never its actual name or arguments -
111
+ // same narrow-purpose exception extractReplyText already sets for
112
+ // touching `content` at all, still never touching real conversation data.
113
+ let toolUseBlocksSoFar = 0;
105
114
  for (const entry of turnLines) {
106
115
  const usage = extractUsage(entry);
107
116
  if (!usage || !usage.messageId) continue;
@@ -110,6 +119,10 @@ export function usageForCurrentTurn(transcriptPath) {
110
119
  lastModel = lastModel || usage.model;
111
120
  lastEffort = lastEffort || usage.effort;
112
121
  }
122
+ const content = entry?.type === 'assistant' ? entry.message?.content : null;
123
+ if (Array.isArray(content) && content.some(b => b?.type === 'tool_use')) {
124
+ toolUseBlocksSoFar++;
125
+ }
113
126
  }
114
127
 
115
128
  if (seen.size === 0) return null;
@@ -137,7 +150,10 @@ export function usageForCurrentTurn(transcriptPath) {
137
150
  }
138
151
  const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
139
152
 
140
- return { tokens: totalTokens, model: lastModel, effort: lastEffort, turnKey, contentExcerpt };
153
+ return {
154
+ tokens: totalTokens, model: lastModel, effort: lastEffort, toolUseBlocksSoFar,
155
+ turnKey, contentExcerpt,
156
+ };
141
157
  }
142
158
 
143
159
  function withTimeout(promise, ms) {
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})`);
@@ -97,6 +97,15 @@ export async function runPreToolUseHook() {
97
97
  // guessed low/medium/high. Omitted when the transcript doesn't carry one
98
98
  // (an older Claude Code build, or a turn before any assistant reply yet).
99
99
  if (usage.effort) body.effort = usage.effort;
100
+ // How many tools this turn has used so far (from the transcript), plus
101
+ // the one about to run that triggered this hook. An undercount by
102
+ // nature - a PreToolUse hook cannot know how many MORE tool calls this
103
+ // turn will end up making - but a real, current-as-of-right-now number,
104
+ // not a guess, and the effort-suggestion feature only needs to tell a
105
+ // turn's first tool call apart from its fifth, not know the eventual
106
+ // total. Without this, effort was accepted but never actually
107
+ // classified as "simple", so no suggestion could ever fire from here.
108
+ body.estimated_tool_count = usage.toolUseBlocksSoFar + 1;
100
109
 
101
110
  const result = await withTimeout(
102
111
  api.evaluateModel(body, apiKey),
@@ -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
  });
@@ -40,6 +40,10 @@ function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5'
40
40
  return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'text', text }] } };
41
41
  }
42
42
 
43
+ function assistantToolUseLine(messageId, usage, toolName = 'Bash', model = 'claude-sonnet-5') {
44
+ return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'tool_use', name: toolName }] } };
45
+ }
46
+
43
47
  const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
44
48
 
45
49
  describe('extractUsage', () => {
@@ -142,6 +146,29 @@ describe('usageForCurrentTurn', () => {
142
146
  assert.equal(result.effort, null);
143
147
  });
144
148
 
149
+ it('counts tool_use content blocks in the current turn, not text/thinking ones', () => {
150
+ writeLines([
151
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
152
+ // Same real shape: one JSONL line per content block, all three lines
153
+ // share message.id 'msg_1' and repeat the same usage object.
154
+ { type: 'assistant', message: { id: 'msg_1', model: 'claude-sonnet-5', usage: { input_tokens: 1, output_tokens: 1 }, content: [{ type: 'thinking', thinking: 'planning' }] } },
155
+ assistantToolUseLine('msg_1', { input_tokens: 1, output_tokens: 1 }, 'Read'),
156
+ assistantToolUseLine('msg_2', { input_tokens: 1, output_tokens: 1 }, 'Bash'),
157
+ assistantLineWithText('msg_3', { input_tokens: 1, output_tokens: 1 }, 'done'),
158
+ ]);
159
+ const result = usageForCurrentTurn(transcriptPath());
160
+ assert.equal(result.toolUseBlocksSoFar, 2);
161
+ });
162
+
163
+ it('toolUseBlocksSoFar is 0 for a turn with no tool use yet', () => {
164
+ writeLines([
165
+ { type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
166
+ assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'just chatting'),
167
+ ]);
168
+ const result = usageForCurrentTurn(transcriptPath());
169
+ assert.equal(result.toolUseBlocksSoFar, 0);
170
+ });
171
+
145
172
  it('returns a stable turn_key for the same set of message ids, order-independent', () => {
146
173
  writeLines([
147
174
  { type: 'user', message: { content: [] } },