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