troxy-cli 1.19.0 → 1.20.0
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 +40 -5
- package/src/tests/hook-report.test.js +66 -1
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 {
|
|
@@ -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
|
+
});
|