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