troxy-cli 1.19.0 → 1.20.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/bin/troxy.js +1 -1
- package/package.json +1 -1
- package/src/hook-report.js +40 -5
- package/src/init.js +2 -4
- package/src/tests/hook-report.test.js +66 -1
package/bin/troxy.js
CHANGED
|
@@ -541,7 +541,7 @@ switch (command) {
|
|
|
541
541
|
default:
|
|
542
542
|
if (command) console.error(` Unknown command: ${command}\n`);
|
|
543
543
|
console.log(`
|
|
544
|
-
Troxy:
|
|
544
|
+
Troxy: a secure control layer for AI agents
|
|
545
545
|
|
|
546
546
|
This CLI has two parts:
|
|
547
547
|
- Commands below (policies, mcps, activity, ...) are for YOU to run and manage your account.
|
package/package.json
CHANGED
package/src/hook-report.js
CHANGED
|
@@ -15,6 +15,7 @@ import { api } from './api.js';
|
|
|
15
15
|
// happening here than the conversation that just finished.
|
|
16
16
|
|
|
17
17
|
const REPORT_TIMEOUT_MS = 2500;
|
|
18
|
+
const MAX_EXCERPT_LEN = 4000; // matches classify.py's MAX_EXCERPT_LEN
|
|
18
19
|
|
|
19
20
|
// Fields this file is allowed to touch from a transcript entry. Deliberately
|
|
20
21
|
// narrow: extractUsage() below only ever reads these keys off a transcript
|
|
@@ -44,6 +45,24 @@ export function extractUsage(entry) {
|
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// A second, equally narrow allowlist function, same discipline as
|
|
49
|
+
// extractUsage above: this is the ONLY place in the whole hook that reads
|
|
50
|
+
// message text at all, and it exists on purpose for the transient
|
|
51
|
+
// classifier (classify.py on the backend) - the excerpt it returns is sent
|
|
52
|
+
// once, classified once, and never stored anywhere, on either side of the
|
|
53
|
+
// wire. Everywhere else in this file continues to only ever touch usage
|
|
54
|
+
// numbers. Returns the LAST text block of an entry, since a single
|
|
55
|
+
// assistant turn can carry several content blocks (thinking, tool_use,
|
|
56
|
+
// text) and only the final text block is what the user actually saw as
|
|
57
|
+
// the reply.
|
|
58
|
+
export function extractReplyText(entry) {
|
|
59
|
+
if (!entry || entry.type !== 'assistant') return null;
|
|
60
|
+
const content = entry.message?.content;
|
|
61
|
+
if (!Array.isArray(content)) return null;
|
|
62
|
+
const textBlock = content.find(b => b?.type === 'text' && typeof b.text === 'string');
|
|
63
|
+
return textBlock ? textBlock.text : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
// Claude Code writes one JSONL line per content block (thinking/tool_use/
|
|
48
67
|
// text), not one per turn or even one per model call - a single real API
|
|
49
68
|
// response is split across several consecutive lines that all share the
|
|
@@ -99,7 +118,19 @@ export function usageForCurrentTurn(transcriptPath) {
|
|
|
99
118
|
// genuinely different turns.
|
|
100
119
|
const turnKey = Array.from(seen.keys()).sort().join(',').slice(0, 200);
|
|
101
120
|
|
|
102
|
-
|
|
121
|
+
// turnLines is most-recent-first (built by walking the transcript
|
|
122
|
+
// backward), so the first entry with a text block is the final reply the
|
|
123
|
+
// user actually saw - not an earlier "thinking" block from mid-turn tool
|
|
124
|
+
// use. Truncated client-side too so an oversized excerpt never leaves
|
|
125
|
+
// this machine in the first place, not just rejected on arrival.
|
|
126
|
+
let replyText = null;
|
|
127
|
+
for (const entry of turnLines) {
|
|
128
|
+
replyText = extractReplyText(entry);
|
|
129
|
+
if (replyText) break;
|
|
130
|
+
}
|
|
131
|
+
const contentExcerpt = replyText ? replyText.slice(0, MAX_EXCERPT_LEN) : null;
|
|
132
|
+
|
|
133
|
+
return { tokens: totalTokens, model: lastModel, turnKey, contentExcerpt };
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
function withTimeout(promise, ms) {
|
|
@@ -147,11 +178,15 @@ export async function runHookReport() {
|
|
|
147
178
|
const sessionId = payload?.session_id || '';
|
|
148
179
|
const turnKey = sessionId ? `${sessionId}:${usage.turnKey}` : null;
|
|
149
180
|
|
|
181
|
+
const body = { model: usage.model, actual_tokens: usage.tokens, turn_key: turnKey };
|
|
182
|
+
// content_excerpt is optional and additive - the backend classifies it
|
|
183
|
+
// if present, samples/rate-limits on its own, and silently skips when
|
|
184
|
+
// it isn't. Sending it is not required for the usage report itself to
|
|
185
|
+
// succeed.
|
|
186
|
+
if (usage.contentExcerpt) body.content_excerpt = usage.contentExcerpt;
|
|
187
|
+
|
|
150
188
|
await withTimeout(
|
|
151
|
-
api.reportVerifiedUsage(
|
|
152
|
-
{ model: usage.model, actual_tokens: usage.tokens, turn_key: turnKey },
|
|
153
|
-
apiKey,
|
|
154
|
-
),
|
|
189
|
+
api.reportVerifiedUsage(body, apiKey),
|
|
155
190
|
REPORT_TIMEOUT_MS,
|
|
156
191
|
);
|
|
157
192
|
} catch {
|
package/src/init.js
CHANGED
|
@@ -246,7 +246,7 @@ export async function runInit({ key, name } = {}) {
|
|
|
246
246
|
process.exit(1);
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
console.log('\n Troxy:
|
|
249
|
+
console.log('\n Troxy: a secure control layer for AI agents\n');
|
|
250
250
|
|
|
251
251
|
// Validate the key by hitting /evaluate (404 = key is valid, TypeError = network failure)
|
|
252
252
|
process.stdout.write(' Validating API key... ');
|
|
@@ -304,9 +304,7 @@ export async function runInit({ key, name } = {}) {
|
|
|
304
304
|
|
|
305
305
|
console.log('\n Your payments are now protected.');
|
|
306
306
|
console.log(' Dashboard → https://dash.troxy.io');
|
|
307
|
-
console.log('\n
|
|
308
|
-
console.log(` troxy pay --merchant "Test" --amount 10`);
|
|
309
|
-
console.log(' This will appear in your dashboard within seconds.\n');
|
|
307
|
+
console.log('\n For more information, visit https://docs.troxy.io\n');
|
|
310
308
|
}
|
|
311
309
|
|
|
312
310
|
function installService(apiKey, agentName) {
|
|
@@ -16,7 +16,7 @@ import fs from 'node:fs';
|
|
|
16
16
|
import os from 'node:os';
|
|
17
17
|
import path from 'node:path';
|
|
18
18
|
|
|
19
|
-
import { usageForCurrentTurn, extractUsage } from '../hook-report.js';
|
|
19
|
+
import { usageForCurrentTurn, extractUsage, extractReplyText } from '../hook-report.js';
|
|
20
20
|
|
|
21
21
|
let dir;
|
|
22
22
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'troxy-transcript-')); });
|
|
@@ -34,6 +34,10 @@ function assistantLine(messageId, usage, model = 'claude-sonnet-5') {
|
|
|
34
34
|
return { type: 'assistant', message: { id: messageId, model, usage } };
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function assistantLineWithText(messageId, usage, text, model = 'claude-sonnet-5') {
|
|
38
|
+
return { type: 'assistant', message: { id: messageId, model, usage, content: [{ type: 'text', text }] } };
|
|
39
|
+
}
|
|
40
|
+
|
|
37
41
|
const USAGE_A = { input_tokens: 2, output_tokens: 1176, cache_creation_input_tokens: 4134, cache_read_input_tokens: 244103 };
|
|
38
42
|
|
|
39
43
|
describe('extractUsage', () => {
|
|
@@ -137,3 +141,64 @@ describe('usageForCurrentTurn', () => {
|
|
|
137
141
|
assert.doesNotThrow(() => usageForCurrentTurn(transcriptPath()));
|
|
138
142
|
});
|
|
139
143
|
});
|
|
144
|
+
|
|
145
|
+
describe('extractReplyText', () => {
|
|
146
|
+
it('reads the text block from an assistant entry', () => {
|
|
147
|
+
const entry = assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'hello there');
|
|
148
|
+
assert.equal(extractReplyText(entry), 'hello there');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('returns null for a non-assistant entry', () => {
|
|
152
|
+
assert.equal(extractReplyText({ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } }), null);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('returns null when there is no text block (e.g. a tool_use-only entry)', () => {
|
|
156
|
+
const entry = { type: 'assistant', message: { content: [{ type: 'tool_use', name: 'Read' }] } };
|
|
157
|
+
assert.equal(extractReplyText(entry), null);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('returns null when content is missing entirely', () => {
|
|
161
|
+
assert.equal(extractReplyText({ type: 'assistant', message: {} }), null);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('usageForCurrentTurn: content excerpt', () => {
|
|
166
|
+
it('returns the final text reply as contentExcerpt', () => {
|
|
167
|
+
writeLines([
|
|
168
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
|
|
169
|
+
assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'the actual reply'),
|
|
170
|
+
]);
|
|
171
|
+
const result = usageForCurrentTurn(transcriptPath());
|
|
172
|
+
assert.equal(result.contentExcerpt, 'the actual reply');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('picks the LAST text block when the turn has several (e.g. thinking then a tool call then a final reply)', () => {
|
|
176
|
+
writeLines([
|
|
177
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
|
|
178
|
+
assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, 'intermediate note'),
|
|
179
|
+
{ type: 'assistant', message: { id: 'msg_1', usage: { input_tokens: 1, output_tokens: 1 }, content: [{ type: 'tool_use', name: 'Read' }] } },
|
|
180
|
+
assistantLineWithText('msg_2', { input_tokens: 1, output_tokens: 1 }, 'final reply to the user'),
|
|
181
|
+
]);
|
|
182
|
+
const result = usageForCurrentTurn(transcriptPath());
|
|
183
|
+
assert.equal(result.contentExcerpt, 'final reply to the user');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('is null when the turn produced no text block at all', () => {
|
|
187
|
+
writeLines([
|
|
188
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
|
|
189
|
+
assistantLine('msg_1', { input_tokens: 1, output_tokens: 1 }),
|
|
190
|
+
]);
|
|
191
|
+
const result = usageForCurrentTurn(transcriptPath());
|
|
192
|
+
assert.equal(result.contentExcerpt, null);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('truncates an oversized reply before it ever leaves usageForCurrentTurn', () => {
|
|
196
|
+
const longText = 'x'.repeat(10000);
|
|
197
|
+
writeLines([
|
|
198
|
+
{ type: 'user', message: { content: [{ type: 'text', text: 'hi' }] } },
|
|
199
|
+
assistantLineWithText('msg_1', { input_tokens: 1, output_tokens: 1 }, longText),
|
|
200
|
+
]);
|
|
201
|
+
const result = usageForCurrentTurn(transcriptPath());
|
|
202
|
+
assert.equal(result.contentExcerpt.length, 4000);
|
|
203
|
+
});
|
|
204
|
+
});
|