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 +1 -1
- package/src/hook-report.js +16 -3
- package/src/init.js +18 -7
- package/src/tests/claude-code-proxy.test.js +34 -15
- package/src/tests/hook-report.test.js +16 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "troxy-cli",
|
|
3
|
-
"version": "1.29.
|
|
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": {
|
package/src/hook-report.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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).
|
|
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})`);
|
|
@@ -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
|
});
|
|
@@ -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' }] } },
|