@traice/codex-collector 0.1.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/README.md +151 -0
- package/cli.mjs +26 -0
- package/collector.mjs +932 -0
- package/install.mjs +469 -0
- package/package.json +43 -0
package/collector.mjs
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import {
|
|
6
|
+
appendFileSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
readdirSync,
|
|
11
|
+
statSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
16
|
+
import { gunzipSync } from 'node:zlib';
|
|
17
|
+
|
|
18
|
+
const DEFAULT_OUT_DIR = '~/.traice/internal-spend-codex';
|
|
19
|
+
const LEGACY_OUT_DIR = '.traice-internal-spend';
|
|
20
|
+
const DEFAULT_INSTALL_CONFIG = `${DEFAULT_OUT_DIR}/install.json`;
|
|
21
|
+
const LEGACY_INSTALL_CONFIG = `${LEGACY_OUT_DIR}/install.json`;
|
|
22
|
+
const DEFAULT_MAX_BATCH_SIZE = 50;
|
|
23
|
+
const MAX_SENT_IDS = 20_000;
|
|
24
|
+
|
|
25
|
+
const args = parseArgs(process.argv.slice(2));
|
|
26
|
+
loadEnvFile(resolve('apps/web/.env'));
|
|
27
|
+
|
|
28
|
+
const configPath = args.get('config') ? resolveHome(args.get('config')) : findDefaultInstallConfigPath();
|
|
29
|
+
const outDir = resolveHome(args.get('out') ?? dirname(configPath));
|
|
30
|
+
const installConfig = readJsonIfExists(configPath) ?? {};
|
|
31
|
+
const config = {
|
|
32
|
+
serverUrl: normalizeUrl(args.get('server-url') ?? installConfig.serverUrl ?? process.env.TRAICE_API_URL ?? 'http://127.0.0.1:3003'),
|
|
33
|
+
apiKey: readApiKey() ?? installConfig.apiKey ?? process.env.TRAICE_API_KEY,
|
|
34
|
+
workspaceSlug: args.get('workspace-slug') ?? installConfig.workspaceSlug ?? 'internal-spend-poc',
|
|
35
|
+
sourceKey: args.get('source-key') ?? installConfig.sourceKey ?? 'codex-local',
|
|
36
|
+
sourceName: args.get('source-name') ?? installConfig.sourceName ?? 'Codex local collector',
|
|
37
|
+
sourceKind: args.get('source-kind') ?? installConfig.sourceKind ?? 'codex_otel',
|
|
38
|
+
tool: args.get('tool') ?? installConfig.tool ?? 'codex',
|
|
39
|
+
category: args.get('category') ?? installConfig.category ?? 'coding_agent',
|
|
40
|
+
employeeEmail: args.get('employee-email') ?? installConfig.employeeEmail ?? process.env.TRAICE_EMPLOYEE_EMAIL,
|
|
41
|
+
employeeName: args.get('employee-name') ?? installConfig.employeeName ?? process.env.TRAICE_EMPLOYEE_NAME,
|
|
42
|
+
employeeExternalId: args.get('employee-external-id') ?? installConfig.employeeExternalId,
|
|
43
|
+
teamName: args.get('team-name') ?? installConfig.teamName ?? process.env.TRAICE_TEAM_NAME,
|
|
44
|
+
teamExternalId: args.get('team-external-id') ?? installConfig.teamExternalId,
|
|
45
|
+
sourcePrincipal: args.get('source-principal') ?? installConfig.sourcePrincipal,
|
|
46
|
+
seatMonthlyUsd: parseOptionalMoney(
|
|
47
|
+
args.get('seat-monthly-usd') ?? installConfig.seatMonthlyUsd ?? process.env.TRAICE_SEAT_MONTHLY_USD,
|
|
48
|
+
),
|
|
49
|
+
codexHome: resolveHome(args.get('codex-home') ?? installConfig.codexHome ?? process.env.CODEX_HOME ?? '~/.codex'),
|
|
50
|
+
listenHost: args.get('listen-host') ?? installConfig.listenHost ?? '127.0.0.1',
|
|
51
|
+
listenPort: parsePort(args.get('listen-port') ?? installConfig.listenPort ?? '4318'),
|
|
52
|
+
pollIntervalMs: parsePositiveInt(args.get('poll-interval-ms') ?? installConfig.pollIntervalMs ?? '3000'),
|
|
53
|
+
backfillHours: Number(args.get('backfill-hours') ?? installConfig.backfillHours ?? 6),
|
|
54
|
+
includeZeroOutput: boolArg(args.get('include-zero-output') ?? installConfig.includeZeroOutput, false),
|
|
55
|
+
maxBatchSize: parsePositiveInt(args.get('max-batch-size') ?? installConfig.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE),
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (!config.apiKey) {
|
|
59
|
+
console.error(
|
|
60
|
+
[
|
|
61
|
+
'Missing collector API key.',
|
|
62
|
+
'Run: npx @traice/codex-collector@latest install',
|
|
63
|
+
'Provide the key with TRAICE_API_KEY or --api-key-stdin.',
|
|
64
|
+
].join('\n'),
|
|
65
|
+
);
|
|
66
|
+
process.exit(2);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
mkdirSync(outDir, { recursive: true });
|
|
70
|
+
const rawFile = resolve(outDir, 'requests.ndjson');
|
|
71
|
+
const eventsFile = resolve(outDir, 'events.ndjson');
|
|
72
|
+
const statePath = resolve(outDir, 'collector-state.json');
|
|
73
|
+
const state = loadState(statePath);
|
|
74
|
+
|
|
75
|
+
const shouldRunOnce = args.get('once') === 'true';
|
|
76
|
+
const sessionTailEnabled = args.get('session-tail') !== 'false';
|
|
77
|
+
const otelListenerEnabled = args.get('otel-listener') !== 'false' && !shouldRunOnce;
|
|
78
|
+
|
|
79
|
+
if (shouldRunOnce) {
|
|
80
|
+
const result = sessionTailEnabled ? await scanCodexSessions() : { candidates: 0, sent: 0, files: 0 };
|
|
81
|
+
saveState(statePath, state);
|
|
82
|
+
console.log(JSON.stringify({ ok: true, mode: 'once', ...result }, null, 2));
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (sessionTailEnabled) {
|
|
87
|
+
scanCodexSessions().catch((error) => {
|
|
88
|
+
console.error(`[codex-otel] session scan failed: ${error.message}`);
|
|
89
|
+
});
|
|
90
|
+
setInterval(() => {
|
|
91
|
+
scanCodexSessions().catch((error) => {
|
|
92
|
+
console.error(`[codex-otel] session scan failed: ${error.message}`);
|
|
93
|
+
});
|
|
94
|
+
}, config.pollIntervalMs);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (otelListenerEnabled) {
|
|
98
|
+
startOtelServer();
|
|
99
|
+
} else if (!sessionTailEnabled) {
|
|
100
|
+
console.error('Nothing to run: both OTel listener and session tail are disabled.');
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
process.on('SIGINT', () => {
|
|
105
|
+
saveState(statePath, state);
|
|
106
|
+
process.exit(0);
|
|
107
|
+
});
|
|
108
|
+
process.on('SIGTERM', () => {
|
|
109
|
+
saveState(statePath, state);
|
|
110
|
+
process.exit(0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
function startOtelServer() {
|
|
114
|
+
const server = createServer(async (req, res) => {
|
|
115
|
+
if (req.method === 'GET') {
|
|
116
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8' });
|
|
117
|
+
res.end(
|
|
118
|
+
[
|
|
119
|
+
'trAIce Internal Spend Codex collector is running.',
|
|
120
|
+
'',
|
|
121
|
+
`OTLP endpoint: http://${config.listenHost}:${config.listenPort}/v1/logs`,
|
|
122
|
+
`Internal usage ingest: ${config.serverUrl}/api/v1/internal-usage`,
|
|
123
|
+
`Internal Spend view: ${config.serverUrl}/dashboard?tab=internal`,
|
|
124
|
+
'',
|
|
125
|
+
'New Codex agents send OTel here when user-level [otel] config is active.',
|
|
126
|
+
'Already-open Codex agents are covered by tailing ~/.codex/sessions token_count events.',
|
|
127
|
+
].join('\n'),
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (req.method !== 'POST') {
|
|
133
|
+
res.writeHead(404).end();
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const receivedAt = new Date().toISOString();
|
|
138
|
+
let body;
|
|
139
|
+
try {
|
|
140
|
+
body = await readRequestBody(req);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
res.writeHead(400).end(`failed to read body: ${error.message}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const contentType = req.headers['content-type'] ?? '';
|
|
147
|
+
const rawEnvelope = {
|
|
148
|
+
received_at: receivedAt,
|
|
149
|
+
method: req.method,
|
|
150
|
+
url: req.url,
|
|
151
|
+
content_type: contentType,
|
|
152
|
+
content_encoding: req.headers['content-encoding'],
|
|
153
|
+
body_base64: contentType.includes('json') ? undefined : body.toString('base64'),
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
let normalized = [];
|
|
157
|
+
if (contentType.includes('json')) {
|
|
158
|
+
try {
|
|
159
|
+
const payload = JSON.parse(body.toString('utf8'));
|
|
160
|
+
rawEnvelope.body = redactSensitive(payload);
|
|
161
|
+
normalized = normalizeOtlpLogs(payload);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
rawEnvelope.parse_error = error.message;
|
|
164
|
+
rawEnvelope.body_text = redactSensitive(body.toString('utf8'));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
appendFileSync(rawFile, `${JSON.stringify(rawEnvelope)}\n`);
|
|
169
|
+
appendNormalizedEvents(normalized);
|
|
170
|
+
|
|
171
|
+
const ingestEvents = normalized.flatMap(otelRecordToIngestEvent).filter(Boolean);
|
|
172
|
+
try {
|
|
173
|
+
const sent = await forwardEvents(ingestEvents);
|
|
174
|
+
if (normalized.length > 0 || sent > 0) {
|
|
175
|
+
console.log(
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
received_at: receivedAt,
|
|
178
|
+
source: 'otel',
|
|
179
|
+
records: normalized.length,
|
|
180
|
+
ingest_candidates: ingestEvents.length,
|
|
181
|
+
sent,
|
|
182
|
+
}),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error(`[codex-otel] internal usage ingest failed: ${error.message}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
190
|
+
res.end('{}');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
server.listen(config.listenPort, config.listenHost, () => {
|
|
194
|
+
console.log(`trAIce Internal Spend Codex collector listening on http://${config.listenHost}:${config.listenPort}/v1/logs`);
|
|
195
|
+
console.log(`Forwarding Codex internal usage to ${config.serverUrl}/api/v1/internal-usage`);
|
|
196
|
+
if (config.seatMonthlyUsd > 0) {
|
|
197
|
+
console.log(`Recording seat subscription commitment: $${config.seatMonthlyUsd}/mo`);
|
|
198
|
+
}
|
|
199
|
+
console.log(`Tailing Codex sessions under ${config.codexHome}/sessions`);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function scanCodexSessions() {
|
|
204
|
+
const sessionsDir = resolve(config.codexHome, 'sessions');
|
|
205
|
+
if (!existsSync(sessionsDir)) return { candidates: 0, sent: 0, files: 0 };
|
|
206
|
+
|
|
207
|
+
const cutoffMs = Date.now() - Math.max(0, config.backfillHours) * 60 * 60 * 1000;
|
|
208
|
+
const threadMetadata = loadThreadMetadata(config.codexHome);
|
|
209
|
+
const files = findSessionFiles(sessionsDir)
|
|
210
|
+
.map((file) => ({ file, stat: statSync(file) }))
|
|
211
|
+
.filter(({ file, stat }) => state.files[file] || stat.mtimeMs >= cutoffMs)
|
|
212
|
+
.sort((a, b) => a.stat.mtimeMs - b.stat.mtimeMs);
|
|
213
|
+
|
|
214
|
+
let candidates = 0;
|
|
215
|
+
let sent = 0;
|
|
216
|
+
let touchedFiles = 0;
|
|
217
|
+
|
|
218
|
+
for (const { file, stat } of files) {
|
|
219
|
+
const parsed = readNewSessionTokenEvents(file, stat, cutoffMs, threadMetadata);
|
|
220
|
+
if (!parsed) continue;
|
|
221
|
+
touchedFiles += 1;
|
|
222
|
+
candidates += parsed.events.length;
|
|
223
|
+
if (parsed.events.length > 0) {
|
|
224
|
+
sent += await forwardEvents(parsed.events);
|
|
225
|
+
}
|
|
226
|
+
state.files[file] = {
|
|
227
|
+
offset: parsed.nextOffset,
|
|
228
|
+
size: stat.size,
|
|
229
|
+
mtimeMs: stat.mtimeMs,
|
|
230
|
+
updatedAt: new Date().toISOString(),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (touchedFiles > 0 || sent > 0) {
|
|
235
|
+
state.lastSessionScanAt = new Date().toISOString();
|
|
236
|
+
saveState(statePath, state);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (sent > 0) {
|
|
240
|
+
console.log(JSON.stringify({ source: 'codex_session_jsonl', candidates, sent, files: touchedFiles }));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { candidates, sent, files: touchedFiles };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function readNewSessionTokenEvents(file, stat, cutoffMs, threadMetadata) {
|
|
247
|
+
const prior = state.files[file];
|
|
248
|
+
if (!prior && stat.size === 0) return null;
|
|
249
|
+
|
|
250
|
+
let startOffset = Number(prior?.offset ?? 0);
|
|
251
|
+
if (!Number.isFinite(startOffset) || startOffset < 0 || startOffset > stat.size) {
|
|
252
|
+
startOffset = 0;
|
|
253
|
+
}
|
|
254
|
+
if (startOffset === stat.size && prior) return null;
|
|
255
|
+
|
|
256
|
+
const buffer = readFileSync(file);
|
|
257
|
+
const chunk = buffer.subarray(startOffset);
|
|
258
|
+
const text = chunk.toString('utf8');
|
|
259
|
+
if (!text) return { events: [], nextOffset: startOffset };
|
|
260
|
+
|
|
261
|
+
const completeChars = text.endsWith('\n') ? text.length : text.lastIndexOf('\n') + 1;
|
|
262
|
+
if (completeChars <= 0) return { events: [], nextOffset: startOffset };
|
|
263
|
+
|
|
264
|
+
const completeText = text.slice(0, completeChars);
|
|
265
|
+
const nextOffset = startOffset + Buffer.byteLength(completeText);
|
|
266
|
+
const threadId = extractThreadId(file);
|
|
267
|
+
const metadata = (threadId && threadMetadata.get(threadId)) || threadMetadata.get(file) || null;
|
|
268
|
+
const isInitialBackfill = !prior;
|
|
269
|
+
const events = [];
|
|
270
|
+
|
|
271
|
+
for (const line of completeText.split('\n')) {
|
|
272
|
+
if (!line) continue;
|
|
273
|
+
let entry;
|
|
274
|
+
try {
|
|
275
|
+
entry = JSON.parse(line);
|
|
276
|
+
} catch {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (entry?.type !== 'event_msg' || entry?.payload?.type !== 'token_count') continue;
|
|
280
|
+
|
|
281
|
+
const tsMs = new Date(entry.timestamp).getTime();
|
|
282
|
+
if (isInitialBackfill && Number.isFinite(tsMs) && tsMs < cutoffMs) continue;
|
|
283
|
+
|
|
284
|
+
const event = sessionTokenCountToIngestEvent({ entry, file, threadId, metadata });
|
|
285
|
+
if (event) events.push(event);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return { events, nextOffset };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function sessionTokenCountToIngestEvent({ entry, file, threadId, metadata }) {
|
|
292
|
+
const usage = entry?.payload?.info?.last_token_usage;
|
|
293
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
294
|
+
|
|
295
|
+
const inputTokens = getNumber(usage, 'input_tokens');
|
|
296
|
+
const cachedInputTokens = getNumber(usage, 'cached_input_tokens');
|
|
297
|
+
const outputTokens = getNumber(usage, 'output_tokens');
|
|
298
|
+
const reasoningOutputTokens = getNumber(usage, 'reasoning_output_tokens');
|
|
299
|
+
const nonCachedInputTokens = Math.max(inputTokens - cachedInputTokens, 0);
|
|
300
|
+
const billableTokenEstimate = nonCachedInputTokens + outputTokens;
|
|
301
|
+
if (!config.includeZeroOutput && outputTokens === 0 && reasoningOutputTokens === 0) return null;
|
|
302
|
+
|
|
303
|
+
const timestamp = normalizeTimestamp(entry.timestamp);
|
|
304
|
+
const model = metadata?.model ?? 'unknown';
|
|
305
|
+
const eventId = hashObject({
|
|
306
|
+
source: 'codex_session_jsonl',
|
|
307
|
+
file,
|
|
308
|
+
timestamp,
|
|
309
|
+
inputTokens,
|
|
310
|
+
cachedInputTokens,
|
|
311
|
+
outputTokens,
|
|
312
|
+
reasoningOutputTokens,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
ts: timestamp,
|
|
317
|
+
sourceKey: config.sourceKey,
|
|
318
|
+
sourceName: config.sourceName,
|
|
319
|
+
sourceKind: config.sourceKind,
|
|
320
|
+
tool: config.tool,
|
|
321
|
+
category: config.category,
|
|
322
|
+
provider: metadata?.modelProvider ?? 'openai',
|
|
323
|
+
model,
|
|
324
|
+
employeeEmail: config.employeeEmail,
|
|
325
|
+
employeeName: config.employeeName,
|
|
326
|
+
employeeExternalId: config.employeeExternalId,
|
|
327
|
+
teamName: config.teamName,
|
|
328
|
+
teamExternalId: config.teamExternalId,
|
|
329
|
+
sourcePrincipal: config.sourcePrincipal,
|
|
330
|
+
sourcePrincipalType: 'device_user',
|
|
331
|
+
runId: threadId ?? basename(file, '.jsonl').slice(-36),
|
|
332
|
+
stepId: eventId.slice(0, 32),
|
|
333
|
+
sourceEventId: eventId,
|
|
334
|
+
inputTokens,
|
|
335
|
+
cachedInputTokens,
|
|
336
|
+
outputTokens,
|
|
337
|
+
totalTokens: inputTokens + outputTokens,
|
|
338
|
+
costUsd: 0,
|
|
339
|
+
costBasis: config.seatMonthlyUsd > 0 ? 'seat_subscription' : 'usage_only',
|
|
340
|
+
status: 'ok',
|
|
341
|
+
metadata: compactMetadata({
|
|
342
|
+
usageDomain: 'developer_agent',
|
|
343
|
+
sourceAgent: 'codex',
|
|
344
|
+
sourceTransport: 'codex_session_jsonl',
|
|
345
|
+
sourceEventName: 'codex.session_token_count',
|
|
346
|
+
workspaceSlug: config.workspaceSlug,
|
|
347
|
+
tokenAccounting: {
|
|
348
|
+
rawInputTokens: inputTokens,
|
|
349
|
+
cachedInputTokens,
|
|
350
|
+
nonCachedInputTokens,
|
|
351
|
+
outputTokens,
|
|
352
|
+
reasoningOutputTokens,
|
|
353
|
+
billableTokenEstimate,
|
|
354
|
+
rawTotalTokens: getNumber(usage, 'total_tokens'),
|
|
355
|
+
cumulative: entry?.payload?.info?.total_token_usage ?? null,
|
|
356
|
+
},
|
|
357
|
+
subscription: subscriptionMetadata(),
|
|
358
|
+
codex: {
|
|
359
|
+
conversationId: threadId,
|
|
360
|
+
sessionFile: compactPath(file),
|
|
361
|
+
eventKind: 'token_count',
|
|
362
|
+
modelContextWindow: getNumber(entry?.payload?.info, 'model_context_window'),
|
|
363
|
+
rateLimits: rateLimitSummary(entry?.payload?.rate_limits),
|
|
364
|
+
},
|
|
365
|
+
thread: metadata
|
|
366
|
+
? {
|
|
367
|
+
title: metadata.title,
|
|
368
|
+
cwd: metadata.cwd,
|
|
369
|
+
source: metadata.source,
|
|
370
|
+
sandboxPolicy: metadata.sandboxPolicy,
|
|
371
|
+
approvalMode: metadata.approvalMode,
|
|
372
|
+
reasoningEffort: metadata.reasoningEffort,
|
|
373
|
+
tokensUsed: metadata.tokensUsed,
|
|
374
|
+
}
|
|
375
|
+
: null,
|
|
376
|
+
}),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function otelRecordToIngestEvent(record) {
|
|
381
|
+
const attributes = record.attributes ?? {};
|
|
382
|
+
if (record.event_name !== 'codex.sse_event') return [];
|
|
383
|
+
if (getString(attributes, 'event.kind') !== 'response.completed') return [];
|
|
384
|
+
if (getString(attributes, 'error.message')) return [];
|
|
385
|
+
|
|
386
|
+
const inputTokens = getNumber(attributes, 'input_token_count');
|
|
387
|
+
const cachedInputTokens = getNumber(attributes, 'cached_token_count');
|
|
388
|
+
const outputTokens = getNumber(attributes, 'output_token_count');
|
|
389
|
+
const reasoningOutputTokens = getNumber(attributes, 'reasoning_token_count');
|
|
390
|
+
const nonCachedInputTokens = Math.max(inputTokens - cachedInputTokens, 0);
|
|
391
|
+
const zeroOutputRecord = outputTokens === 0 && reasoningOutputTokens === 0;
|
|
392
|
+
if (!config.includeZeroOutput && zeroOutputRecord) return [];
|
|
393
|
+
|
|
394
|
+
const timestamp = normalizeTimestamp(getString(attributes, 'event.timestamp') ?? record.received_at);
|
|
395
|
+
const conversationId = getString(attributes, 'conversation.id') ?? 'unknown';
|
|
396
|
+
const model = getString(attributes, 'model') ?? 'unknown';
|
|
397
|
+
const eventId = hashObject({
|
|
398
|
+
source: 'otel',
|
|
399
|
+
conversationId,
|
|
400
|
+
timestamp,
|
|
401
|
+
model,
|
|
402
|
+
inputTokens,
|
|
403
|
+
cachedInputTokens,
|
|
404
|
+
outputTokens,
|
|
405
|
+
reasoningOutputTokens,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return [
|
|
409
|
+
{
|
|
410
|
+
ts: timestamp,
|
|
411
|
+
sourceKey: config.sourceKey,
|
|
412
|
+
sourceName: config.sourceName,
|
|
413
|
+
sourceKind: config.sourceKind,
|
|
414
|
+
tool: config.tool,
|
|
415
|
+
category: config.category,
|
|
416
|
+
provider: 'openai',
|
|
417
|
+
model,
|
|
418
|
+
employeeEmail: config.employeeEmail,
|
|
419
|
+
employeeName: config.employeeName,
|
|
420
|
+
employeeExternalId: config.employeeExternalId,
|
|
421
|
+
teamName: config.teamName,
|
|
422
|
+
teamExternalId: config.teamExternalId,
|
|
423
|
+
sourcePrincipal: config.sourcePrincipal,
|
|
424
|
+
sourcePrincipalType: 'device_user',
|
|
425
|
+
runId: conversationId,
|
|
426
|
+
stepId: eventId.slice(0, 32),
|
|
427
|
+
sourceEventId: eventId,
|
|
428
|
+
inputTokens,
|
|
429
|
+
cachedInputTokens,
|
|
430
|
+
outputTokens,
|
|
431
|
+
totalTokens: inputTokens + outputTokens,
|
|
432
|
+
costUsd: 0,
|
|
433
|
+
costBasis: config.seatMonthlyUsd > 0 ? 'seat_subscription' : 'usage_only',
|
|
434
|
+
latencyMs: getNullableNumber(attributes, 'duration_ms') ?? undefined,
|
|
435
|
+
status: 'ok',
|
|
436
|
+
metadata: compactMetadata({
|
|
437
|
+
usageDomain: 'developer_agent',
|
|
438
|
+
sourceAgent: 'codex',
|
|
439
|
+
sourceTransport: 'otel',
|
|
440
|
+
sourceEventName: record.event_name,
|
|
441
|
+
workspaceSlug: config.workspaceSlug,
|
|
442
|
+
tokenAccounting: {
|
|
443
|
+
rawInputTokens: inputTokens,
|
|
444
|
+
cachedInputTokens,
|
|
445
|
+
nonCachedInputTokens,
|
|
446
|
+
outputTokens,
|
|
447
|
+
reasoningOutputTokens,
|
|
448
|
+
billableTokenEstimate: nonCachedInputTokens + outputTokens,
|
|
449
|
+
zeroOutputRecord,
|
|
450
|
+
},
|
|
451
|
+
subscription: subscriptionMetadata(),
|
|
452
|
+
service: {
|
|
453
|
+
name: getString(record.resource, 'service.name'),
|
|
454
|
+
version: getString(record.resource, 'service.version'),
|
|
455
|
+
environment: getString(record.resource, 'env'),
|
|
456
|
+
},
|
|
457
|
+
codex: {
|
|
458
|
+
conversationId,
|
|
459
|
+
eventKind: getString(attributes, 'event.kind'),
|
|
460
|
+
authMode: getString(attributes, 'auth_mode'),
|
|
461
|
+
sandboxPolicy: getString(attributes, 'sandbox_policy'),
|
|
462
|
+
approvalPolicy: getString(attributes, 'approval_policy'),
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
},
|
|
466
|
+
];
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function forwardEvents(events) {
|
|
470
|
+
const pending = events.filter((event) => {
|
|
471
|
+
const sourceEventId = event?.sourceEventId;
|
|
472
|
+
return sourceEventId && !state.sent[sourceEventId];
|
|
473
|
+
});
|
|
474
|
+
if (pending.length === 0) return 0;
|
|
475
|
+
|
|
476
|
+
let sent = 0;
|
|
477
|
+
for (let i = 0; i < pending.length; i += config.maxBatchSize) {
|
|
478
|
+
const batch = pending.slice(i, i + config.maxBatchSize);
|
|
479
|
+
const response = await fetch(`${config.serverUrl}/api/v1/internal-usage`, {
|
|
480
|
+
method: 'POST',
|
|
481
|
+
headers: {
|
|
482
|
+
authorization: `Bearer ${config.apiKey}`,
|
|
483
|
+
'content-type': 'application/json',
|
|
484
|
+
},
|
|
485
|
+
body: JSON.stringify({ events: batch }),
|
|
486
|
+
});
|
|
487
|
+
if (!response.ok) {
|
|
488
|
+
const text = await response.text().catch(() => '');
|
|
489
|
+
throw new Error(`POST /api/v1/internal-usage returned ${response.status}: ${text.slice(0, 500)}`);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const body = await response.json().catch(() => ({}));
|
|
493
|
+
const accepted = Number(body.accepted ?? batch.length);
|
|
494
|
+
for (const event of batch.slice(0, accepted)) {
|
|
495
|
+
state.sent[event.sourceEventId] = new Date().toISOString();
|
|
496
|
+
}
|
|
497
|
+
sent += accepted;
|
|
498
|
+
pruneSentIds();
|
|
499
|
+
saveState(statePath, state);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return sent;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function normalizeOtlpLogs(payload) {
|
|
506
|
+
const normalized = [];
|
|
507
|
+
const resourceLogs = payload.resourceLogs ?? payload.resource_logs ?? [];
|
|
508
|
+
|
|
509
|
+
for (const resourceLog of resourceLogs) {
|
|
510
|
+
const resource = keyValuesToObject(resourceLog.resource?.attributes ?? []);
|
|
511
|
+
const scopeLogs = resourceLog.scopeLogs ?? resourceLog.scope_logs ?? [];
|
|
512
|
+
|
|
513
|
+
for (const scopeLog of scopeLogs) {
|
|
514
|
+
const scope = {
|
|
515
|
+
name: scopeLog.scope?.name,
|
|
516
|
+
version: scopeLog.scope?.version,
|
|
517
|
+
attributes: keyValuesToObject(scopeLog.scope?.attributes ?? []),
|
|
518
|
+
};
|
|
519
|
+
const logRecords = scopeLog.logRecords ?? scopeLog.log_records ?? [];
|
|
520
|
+
|
|
521
|
+
for (const logRecord of logRecords) {
|
|
522
|
+
const attributes = keyValuesToObject(logRecord.attributes ?? []);
|
|
523
|
+
const body = maybeParseJsonString(otlpValueToJs(logRecord.body));
|
|
524
|
+
normalized.push({
|
|
525
|
+
received_at: new Date().toISOString(),
|
|
526
|
+
time_unix_nano: logRecord.timeUnixNano ?? logRecord.time_unix_nano,
|
|
527
|
+
severity_text: logRecord.severityText ?? logRecord.severity_text,
|
|
528
|
+
event_name: extractEventName(body, attributes),
|
|
529
|
+
resource: redactSensitive(resource),
|
|
530
|
+
scope: redactSensitive(scope),
|
|
531
|
+
attributes: redactSensitive(attributes),
|
|
532
|
+
body: redactSensitive(body),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return normalized;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function appendNormalizedEvents(records) {
|
|
542
|
+
for (const record of records) {
|
|
543
|
+
appendFileSync(eventsFile, `${JSON.stringify(record)}\n`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function loadThreadMetadata(codexHome) {
|
|
548
|
+
const byId = new Map();
|
|
549
|
+
const dbPath = resolve(codexHome, 'state_5.sqlite');
|
|
550
|
+
if (!existsSync(dbPath)) return byId;
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
const query = [
|
|
554
|
+
'select id, rollout_path, source, model_provider, cwd, title, sandbox_policy,',
|
|
555
|
+
'approval_mode, tokens_used, model, reasoning_effort, created_at, updated_at',
|
|
556
|
+
'from threads',
|
|
557
|
+
].join(' ');
|
|
558
|
+
const output = execFileSync('sqlite3', ['-json', dbPath, query], {
|
|
559
|
+
encoding: 'utf8',
|
|
560
|
+
timeout: 3000,
|
|
561
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
562
|
+
});
|
|
563
|
+
const rows = JSON.parse(output || '[]');
|
|
564
|
+
for (const row of rows) {
|
|
565
|
+
const metadata = {
|
|
566
|
+
id: row.id,
|
|
567
|
+
rolloutPath: row.rollout_path,
|
|
568
|
+
source: row.source,
|
|
569
|
+
modelProvider: row.model_provider,
|
|
570
|
+
cwd: row.cwd,
|
|
571
|
+
title: row.title,
|
|
572
|
+
sandboxPolicy: row.sandbox_policy,
|
|
573
|
+
approvalMode: row.approval_mode,
|
|
574
|
+
tokensUsed: row.tokens_used,
|
|
575
|
+
model: row.model,
|
|
576
|
+
reasoningEffort: row.reasoning_effort,
|
|
577
|
+
createdAt: epochToIso(row.created_at),
|
|
578
|
+
updatedAt: epochToIso(row.updated_at),
|
|
579
|
+
};
|
|
580
|
+
if (row.id) byId.set(row.id, metadata);
|
|
581
|
+
if (row.rollout_path) byId.set(row.rollout_path, metadata);
|
|
582
|
+
}
|
|
583
|
+
} catch (error) {
|
|
584
|
+
console.error(`[codex-otel] could not read Codex thread metadata: ${error.message}`);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return byId;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function findSessionFiles(dir) {
|
|
591
|
+
const files = [];
|
|
592
|
+
const visit = (current) => {
|
|
593
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
594
|
+
const fullPath = join(current, entry.name);
|
|
595
|
+
if (entry.isDirectory()) {
|
|
596
|
+
visit(fullPath);
|
|
597
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
598
|
+
files.push(fullPath);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
visit(dir);
|
|
603
|
+
return files;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function readRequestBody(req) {
|
|
607
|
+
return new Promise((resolveBody, rejectBody) => {
|
|
608
|
+
const chunks = [];
|
|
609
|
+
req.on('data', (chunk) => chunks.push(chunk));
|
|
610
|
+
req.on('end', () => {
|
|
611
|
+
const body = Buffer.concat(chunks);
|
|
612
|
+
if (req.headers['content-encoding'] === 'gzip') {
|
|
613
|
+
try {
|
|
614
|
+
resolveBody(gunzipSync(body));
|
|
615
|
+
} catch (error) {
|
|
616
|
+
rejectBody(error);
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
resolveBody(body);
|
|
621
|
+
});
|
|
622
|
+
req.on('error', rejectBody);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function otlpValueToJs(value) {
|
|
627
|
+
if (!value || typeof value !== 'object') return value;
|
|
628
|
+
if ('stringValue' in value) return value.stringValue;
|
|
629
|
+
if ('intValue' in value) return Number(value.intValue);
|
|
630
|
+
if ('doubleValue' in value) return Number(value.doubleValue);
|
|
631
|
+
if ('boolValue' in value) return Boolean(value.boolValue);
|
|
632
|
+
if ('bytesValue' in value) return value.bytesValue;
|
|
633
|
+
if ('arrayValue' in value) return (value.arrayValue?.values ?? []).map(otlpValueToJs);
|
|
634
|
+
if ('kvlistValue' in value) return keyValuesToObject(value.kvlistValue?.values ?? []);
|
|
635
|
+
return value;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function keyValuesToObject(values) {
|
|
639
|
+
const out = {};
|
|
640
|
+
for (const item of values ?? []) {
|
|
641
|
+
if (!item?.key) continue;
|
|
642
|
+
out[item.key] = otlpValueToJs(item.value);
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function maybeParseJsonString(value) {
|
|
648
|
+
if (typeof value !== 'string') return value;
|
|
649
|
+
const trimmed = value.trim();
|
|
650
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return value;
|
|
651
|
+
try {
|
|
652
|
+
return JSON.parse(trimmed);
|
|
653
|
+
} catch {
|
|
654
|
+
return value;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function extractEventName(body, attributes) {
|
|
659
|
+
if (typeof body === 'string') return body;
|
|
660
|
+
if (body && typeof body === 'object') {
|
|
661
|
+
return (
|
|
662
|
+
body.event_name ??
|
|
663
|
+
body.eventName ??
|
|
664
|
+
body.event ??
|
|
665
|
+
body.name ??
|
|
666
|
+
body.type ??
|
|
667
|
+
body.message ??
|
|
668
|
+
attributes['event.name'] ??
|
|
669
|
+
attributes.event_name ??
|
|
670
|
+
attributes.name ??
|
|
671
|
+
'unknown'
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
return attributes['event.name'] ?? attributes.event_name ?? attributes.name ?? 'unknown';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function redactSensitive(value, path = '') {
|
|
678
|
+
if (value === null || value === undefined) return value;
|
|
679
|
+
if (Array.isArray(value)) return value.map((item, index) => redactSensitive(item, `${path}.${index}`));
|
|
680
|
+
if (typeof value === 'object') {
|
|
681
|
+
const out = {};
|
|
682
|
+
for (const [key, child] of Object.entries(value)) {
|
|
683
|
+
const childPath = path ? `${path}.${key}` : key;
|
|
684
|
+
out[key] = isSensitiveKey(childPath) || isSensitiveKey(key) ? '[REDACTED]' : redactSensitive(child, childPath);
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
}
|
|
688
|
+
if (typeof value === 'string' && /\b[^@\s]+@[^@\s]+\.[^@\s]+\b/.test(value)) return '[REDACTED]';
|
|
689
|
+
return value;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function isSensitiveKey(key) {
|
|
693
|
+
return /(^|\.|_)(authorization|cookie|api[_-]?key|secret|bearer|access[_-]?token|refresh[_-]?token|id[_-]?token|user\.email|user\.account_id|email|account_id|host\.name)$/i.test(
|
|
694
|
+
key,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function compactMetadata(metadata) {
|
|
699
|
+
return pruneNullish(metadata);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function subscriptionMetadata() {
|
|
703
|
+
if (config.seatMonthlyUsd <= 0) return null;
|
|
704
|
+
return {
|
|
705
|
+
monthlySeatUsd: config.seatMonthlyUsd,
|
|
706
|
+
allocation: 'monthly_seat_commitment',
|
|
707
|
+
note: 'Subscription commitment is shown separately from per-request usage cost.',
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function pruneNullish(value) {
|
|
712
|
+
if (Array.isArray(value)) {
|
|
713
|
+
return value.map(pruneNullish).filter((item) => item !== undefined);
|
|
714
|
+
}
|
|
715
|
+
if (value && typeof value === 'object') {
|
|
716
|
+
const out = {};
|
|
717
|
+
for (const [key, child] of Object.entries(value)) {
|
|
718
|
+
const pruned = pruneNullish(child);
|
|
719
|
+
if (pruned !== undefined) out[key] = pruned;
|
|
720
|
+
}
|
|
721
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
722
|
+
}
|
|
723
|
+
return value === null || value === undefined || value === '' ? undefined : value;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function rateLimitSummary(rateLimits) {
|
|
727
|
+
if (!rateLimits || typeof rateLimits !== 'object') return null;
|
|
728
|
+
return {
|
|
729
|
+
limitId: rateLimits.limit_id,
|
|
730
|
+
planType: rateLimits.plan_type,
|
|
731
|
+
rateLimitReachedType: rateLimits.rate_limit_reached_type,
|
|
732
|
+
primary: rateLimits.primary
|
|
733
|
+
? {
|
|
734
|
+
usedPercent: rateLimits.primary.used_percent,
|
|
735
|
+
windowMinutes: rateLimits.primary.window_minutes,
|
|
736
|
+
resetsAt: epochToIso(rateLimits.primary.resets_at),
|
|
737
|
+
}
|
|
738
|
+
: null,
|
|
739
|
+
secondary: rateLimits.secondary
|
|
740
|
+
? {
|
|
741
|
+
usedPercent: rateLimits.secondary.used_percent,
|
|
742
|
+
windowMinutes: rateLimits.secondary.window_minutes,
|
|
743
|
+
resetsAt: epochToIso(rateLimits.secondary.resets_at),
|
|
744
|
+
}
|
|
745
|
+
: null,
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function loadState(path) {
|
|
750
|
+
const fallback = { version: 1, files: {}, sent: {} };
|
|
751
|
+
if (!existsSync(path)) return fallback;
|
|
752
|
+
try {
|
|
753
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
754
|
+
return {
|
|
755
|
+
version: 1,
|
|
756
|
+
files: parsed.files && typeof parsed.files === 'object' ? parsed.files : {},
|
|
757
|
+
sent: parsed.sent && typeof parsed.sent === 'object' ? parsed.sent : {},
|
|
758
|
+
lastSessionScanAt: parsed.lastSessionScanAt,
|
|
759
|
+
};
|
|
760
|
+
} catch {
|
|
761
|
+
return fallback;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function saveState(path, nextState) {
|
|
766
|
+
writeFileSync(path, `${JSON.stringify(nextState, null, 2)}\n`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function pruneSentIds() {
|
|
770
|
+
const entries = Object.entries(state.sent);
|
|
771
|
+
if (entries.length <= MAX_SENT_IDS) return;
|
|
772
|
+
entries
|
|
773
|
+
.sort((a, b) => String(b[1]).localeCompare(String(a[1])))
|
|
774
|
+
.slice(MAX_SENT_IDS)
|
|
775
|
+
.forEach(([id]) => {
|
|
776
|
+
delete state.sent[id];
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function parseArgs(argv) {
|
|
781
|
+
const parsed = new Map();
|
|
782
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
783
|
+
const arg = argv[i];
|
|
784
|
+
if (!arg.startsWith('--')) continue;
|
|
785
|
+
const key = arg.slice(2);
|
|
786
|
+
const next = argv[i + 1];
|
|
787
|
+
if (next && !next.startsWith('--')) {
|
|
788
|
+
parsed.set(key, next);
|
|
789
|
+
i += 1;
|
|
790
|
+
} else {
|
|
791
|
+
parsed.set(key, 'true');
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return parsed;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function loadEnvFile(path) {
|
|
798
|
+
if (!existsSync(path)) return;
|
|
799
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
800
|
+
const trimmed = line.trim();
|
|
801
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
802
|
+
const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
|
803
|
+
if (!match) continue;
|
|
804
|
+
const [, key, rawValue] = match;
|
|
805
|
+
if (process.env[key] != null) continue;
|
|
806
|
+
process.env[key] = unquoteEnvValue(rawValue.trim());
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
function unquoteEnvValue(value) {
|
|
811
|
+
if (
|
|
812
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
813
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
814
|
+
) {
|
|
815
|
+
return value.slice(1, -1);
|
|
816
|
+
}
|
|
817
|
+
return value;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function readJsonIfExists(path) {
|
|
821
|
+
if (!existsSync(path)) return null;
|
|
822
|
+
try {
|
|
823
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
824
|
+
} catch {
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function normalizeUrl(value) {
|
|
830
|
+
return String(value).replace(/\/+$/, '');
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
function findDefaultInstallConfigPath() {
|
|
834
|
+
const primaryPath = resolveHome(DEFAULT_INSTALL_CONFIG);
|
|
835
|
+
if (existsSync(primaryPath)) return primaryPath;
|
|
836
|
+
|
|
837
|
+
const legacyPath = resolve(LEGACY_INSTALL_CONFIG);
|
|
838
|
+
if (existsSync(legacyPath)) return legacyPath;
|
|
839
|
+
|
|
840
|
+
return primaryPath;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function resolveHome(value) {
|
|
844
|
+
if (value === '~') return homedir();
|
|
845
|
+
if (String(value).startsWith('~/')) return resolve(homedir(), String(value).slice(2));
|
|
846
|
+
return resolve(String(value));
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function parsePort(value) {
|
|
850
|
+
const port = Number(value);
|
|
851
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
852
|
+
console.error(`Invalid listen port: ${value}`);
|
|
853
|
+
process.exit(2);
|
|
854
|
+
}
|
|
855
|
+
return port;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function parsePositiveInt(value) {
|
|
859
|
+
const parsed = Number(value);
|
|
860
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
861
|
+
console.error(`Invalid positive integer: ${value}`);
|
|
862
|
+
process.exit(2);
|
|
863
|
+
}
|
|
864
|
+
return parsed;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function parseOptionalMoney(value) {
|
|
868
|
+
if (value === undefined || value === null || value === '') return 0;
|
|
869
|
+
const parsed = Number(String(value).replace(/[$,]/g, ''));
|
|
870
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
871
|
+
console.error(`Invalid USD amount: ${value}`);
|
|
872
|
+
process.exit(2);
|
|
873
|
+
}
|
|
874
|
+
return parsed;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function boolArg(value, fallback) {
|
|
878
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
879
|
+
if (typeof value === 'boolean') return value;
|
|
880
|
+
return String(value) === 'true';
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function readApiKey() {
|
|
884
|
+
if (args.get('api-key-stdin') !== 'true') return args.get('api-key');
|
|
885
|
+
return readFileSync(0, 'utf8').trim();
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function getString(source, key) {
|
|
889
|
+
const value = source?.[key];
|
|
890
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function getNumber(source, key) {
|
|
894
|
+
return getNullableNumber(source, key) ?? 0;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function getNullableNumber(source, key) {
|
|
898
|
+
const value = source?.[key];
|
|
899
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
900
|
+
if (typeof value === 'string') {
|
|
901
|
+
const parsed = Number(value);
|
|
902
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
903
|
+
}
|
|
904
|
+
return null;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function normalizeTimestamp(value) {
|
|
908
|
+
const date = value ? new Date(value) : new Date();
|
|
909
|
+
return Number.isNaN(date.getTime()) ? new Date().toISOString() : date.toISOString();
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
function epochToIso(value) {
|
|
913
|
+
const parsed = Number(value);
|
|
914
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
915
|
+
const ms = parsed > 10_000_000_000 ? parsed : parsed * 1000;
|
|
916
|
+
const date = new Date(ms);
|
|
917
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function extractThreadId(file) {
|
|
921
|
+
const match = basename(file).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
|
|
922
|
+
return match?.[1] ?? null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function compactPath(file) {
|
|
926
|
+
const home = homedir();
|
|
927
|
+
return file.startsWith(home) ? `~${file.slice(home.length)}` : file;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function hashObject(value) {
|
|
931
|
+
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
932
|
+
}
|