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 +1 -1
- package/src/hook-report.js +17 -1
- package/src/init.js +18 -7
- package/src/pretooluse-hook.js +9 -0
- package/src/tests/claude-code-proxy.test.js +34 -15
- package/src/tests/hook-report.test.js +27 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.
|
|
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": {
|
package/src/hook-report.js
CHANGED
|
@@ -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 {
|
|
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).
|
|
613
|
-
console.log('
|
|
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})`);
|
package/src/pretooluse-hook.js
CHANGED
|
@@ -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
|
|
3
|
-
// settings.json `env` block, so its own model calls route
|
|
4
|
-
// proxy.troxy.io instead of straight to Anthropic
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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: [] } },
|