@bitkyc08/opencodex 2.7.28 → 2.7.29
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.ko.md +1 -0
- package/README.md +1 -0
- package/README.zh-CN.md +1 -0
- package/bin/ocx.mjs +41 -3
- package/gui/dist/assets/{index-TZysP4q4.js → index-CMKZnkG9.js} +5 -5
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/account-api.ts +266 -0
- package/src/cli/account-extended.ts +282 -0
- package/src/cli/account.ts +265 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +58 -17
- package/src/cli/init.ts +11 -1
- package/src/lib/winsw.ts +14 -3
- package/src/oauth/login-cli.ts +15 -4
- package/src/providers/registry.ts +21 -1
- package/src/server/index.ts +5 -0
- package/src/server/ports.ts +20 -0
- package/src/server/request-log.ts +98 -0
- package/src/service.ts +24 -4
- package/src/update/index.ts +35 -8
- package/src/update/job.ts +34 -7
- package/src/usage/log.ts +98 -1
|
@@ -11,11 +11,13 @@ import type { OcxUsage } from "../types";
|
|
|
11
11
|
import { redactSecretString } from "../lib/redact";
|
|
12
12
|
import {
|
|
13
13
|
appendUsageEntry,
|
|
14
|
+
readRecentUsageEntries,
|
|
14
15
|
usageForFinalLog,
|
|
15
16
|
usageStatusForFinalLog,
|
|
16
17
|
usageTotalTokens,
|
|
17
18
|
type AttemptRecoveryKind,
|
|
18
19
|
type PersistedUsageAttempt,
|
|
20
|
+
type PersistedUsageEntry,
|
|
19
21
|
type UsageStatus,
|
|
20
22
|
} from "../usage/log";
|
|
21
23
|
import {
|
|
@@ -95,6 +97,92 @@ export interface RequestLogEntry {
|
|
|
95
97
|
const requestLog: RequestLogEntry[] = [];
|
|
96
98
|
const MAX_LOG_SIZE = 200;
|
|
97
99
|
let requestLogSeq = 0;
|
|
100
|
+
/** True after hydrateRequestLogsFromDisk ran once in this process. */
|
|
101
|
+
let requestLogsHydratedFromDisk = false;
|
|
102
|
+
|
|
103
|
+
function asTerminalStatus(value: string | undefined): ResponsesTerminalStatus | undefined {
|
|
104
|
+
if (value === "completed" || value === "failed" || value === "incomplete") return value;
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function asCloseReason(value: string | undefined): RequestLogEntry["closeReason"] | undefined {
|
|
109
|
+
switch (value) {
|
|
110
|
+
case "terminal":
|
|
111
|
+
case "client_cancel":
|
|
112
|
+
case "non_stream":
|
|
113
|
+
case "body_stall":
|
|
114
|
+
case "body_overflow":
|
|
115
|
+
return value;
|
|
116
|
+
default:
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Project a persisted usage.jsonl row back into the in-memory /api/logs shape. */
|
|
122
|
+
export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): RequestLogEntry {
|
|
123
|
+
const terminalStatus = asTerminalStatus(entry.terminalStatus);
|
|
124
|
+
const closeReason = asCloseReason(entry.closeReason);
|
|
125
|
+
return {
|
|
126
|
+
requestId: entry.requestId,
|
|
127
|
+
timestamp: entry.timestamp,
|
|
128
|
+
model: entry.model,
|
|
129
|
+
provider: entry.provider,
|
|
130
|
+
...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
|
|
131
|
+
...(entry.surface === "claude" ? { surface: entry.surface } : {}),
|
|
132
|
+
...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
|
|
133
|
+
...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}),
|
|
134
|
+
...(entry.requestedServiceTier ? { requestedServiceTier: entry.requestedServiceTier } : {}),
|
|
135
|
+
...(entry.requestedSpeedLabel ? { requestedSpeedLabel: entry.requestedSpeedLabel } : {}),
|
|
136
|
+
...(entry.configuredServiceTier ? { configuredServiceTier: entry.configuredServiceTier } : {}),
|
|
137
|
+
...(entry.configuredSpeedLabel ? { configuredSpeedLabel: entry.configuredSpeedLabel } : {}),
|
|
138
|
+
...(entry.modelSupportsServiceTier !== undefined
|
|
139
|
+
? { modelSupportsServiceTier: entry.modelSupportsServiceTier }
|
|
140
|
+
: {}),
|
|
141
|
+
...(entry.responseServiceTier ? { responseServiceTier: entry.responseServiceTier } : {}),
|
|
142
|
+
...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
|
|
143
|
+
status: entry.status,
|
|
144
|
+
durationMs: entry.durationMs,
|
|
145
|
+
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
|
|
146
|
+
...(terminalStatus ? { terminalStatus } : {}),
|
|
147
|
+
...(closeReason ? { closeReason } : {}),
|
|
148
|
+
...(entry.upstreamError ? { upstreamError: entry.upstreamError } : {}),
|
|
149
|
+
usageStatus: entry.usageStatus,
|
|
150
|
+
...(entry.usage ? { usage: entry.usage } : {}),
|
|
151
|
+
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
|
|
152
|
+
...(entry.attempts?.length ? { attempts: entry.attempts } : {}),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Seed the in-memory Logs ring buffer from usage.jsonl so GUI /api/logs survives
|
|
158
|
+
* `ocx stop` / `ocx start` (process restart). Idempotent per process; no-ops when
|
|
159
|
+
* the buffer already has live entries. Read failures are non-fatal (same as /api/usage).
|
|
160
|
+
*/
|
|
161
|
+
export function hydrateRequestLogsFromDisk(
|
|
162
|
+
reader: () => PersistedUsageEntry[] = () => readRecentUsageEntries(MAX_LOG_SIZE),
|
|
163
|
+
): number {
|
|
164
|
+
if (requestLogsHydratedFromDisk) return 0;
|
|
165
|
+
if (requestLog.length > 0) {
|
|
166
|
+
requestLogsHydratedFromDisk = true;
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
const persisted = reader();
|
|
171
|
+
requestLogsHydratedFromDisk = true;
|
|
172
|
+
if (persisted.length === 0) return 0;
|
|
173
|
+
const slice = persisted.length > MAX_LOG_SIZE
|
|
174
|
+
? persisted.slice(persisted.length - MAX_LOG_SIZE)
|
|
175
|
+
: persisted;
|
|
176
|
+
for (const entry of slice) requestLog.push(requestLogEntryFromPersistedUsage(entry));
|
|
177
|
+
return slice.length;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
requestLogsHydratedFromDisk = true;
|
|
180
|
+
console.warn(
|
|
181
|
+
`[request-log] failed to hydrate from usage.jsonl: ${err instanceof Error ? err.message : String(err)}`,
|
|
182
|
+
);
|
|
183
|
+
return 0;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
98
186
|
|
|
99
187
|
export function addRequestLog(entry: RequestLogEntry) {
|
|
100
188
|
requestLog.push(entry);
|
|
@@ -119,6 +207,15 @@ export function addRequestLog(entry: RequestLogEntry) {
|
|
|
119
207
|
...(entry.surface === "claude" ? { surface: entry.surface } : {}),
|
|
120
208
|
...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
|
|
121
209
|
...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
|
|
210
|
+
...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}),
|
|
211
|
+
...(entry.requestedServiceTier ? { requestedServiceTier: entry.requestedServiceTier } : {}),
|
|
212
|
+
...(entry.requestedSpeedLabel ? { requestedSpeedLabel: entry.requestedSpeedLabel } : {}),
|
|
213
|
+
...(entry.configuredServiceTier ? { configuredServiceTier: entry.configuredServiceTier } : {}),
|
|
214
|
+
...(entry.configuredSpeedLabel ? { configuredSpeedLabel: entry.configuredSpeedLabel } : {}),
|
|
215
|
+
...(entry.modelSupportsServiceTier !== undefined
|
|
216
|
+
? { modelSupportsServiceTier: entry.modelSupportsServiceTier }
|
|
217
|
+
: {}),
|
|
218
|
+
...(entry.responseServiceTier ? { responseServiceTier: entry.responseServiceTier } : {}),
|
|
122
219
|
status: entry.status,
|
|
123
220
|
durationMs: entry.durationMs,
|
|
124
221
|
...(entry.firstOutputMs !== undefined ? { firstOutputMs: entry.firstOutputMs } : {}),
|
|
@@ -687,4 +784,5 @@ export function getRequestLogEntries(): RequestLogEntry[] { return requestLog; }
|
|
|
687
784
|
export function clearRequestLogsForTests(): void {
|
|
688
785
|
requestLog.length = 0;
|
|
689
786
|
requestLogSeq = 0;
|
|
787
|
+
requestLogsHydratedFromDisk = false;
|
|
690
788
|
}
|
package/src/service.ts
CHANGED
|
@@ -242,9 +242,29 @@ function shellQuote(value: string): string {
|
|
|
242
242
|
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
/**
|
|
246
|
+
* Listen port baked into service wrappers / WinSW XML.
|
|
247
|
+
* Priority: explicit override → OCX_BAKE_PORT (update restart) → config.port → 10100.
|
|
248
|
+
* `config.port === 0` means ephemeral for interactive start; services need a stable pin,
|
|
249
|
+
* so treat 0 / invalid like unset (default 10100) instead of baking `--port 0`.
|
|
250
|
+
*/
|
|
251
|
+
export function resolveServiceListenPort(override?: number): number {
|
|
252
|
+
if (typeof override === "number" && Number.isFinite(override) && override > 0 && override <= 65535) {
|
|
253
|
+
return Math.trunc(override);
|
|
254
|
+
}
|
|
255
|
+
const baked = process.env.OCX_BAKE_PORT?.trim();
|
|
256
|
+
if (baked && /^\d+$/.test(baked)) {
|
|
257
|
+
const n = Number(baked);
|
|
258
|
+
if (n > 0 && n <= 65535) return n;
|
|
259
|
+
}
|
|
260
|
+
const configured = loadConfig().port;
|
|
261
|
+
if (typeof configured === "number" && configured > 0 && configured <= 65535) return configured;
|
|
262
|
+
return 10100;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function buildServiceShellCommand(bun: string, cli: string, port = resolveServiceListenPort()): string {
|
|
246
266
|
const tokenFile = serviceApiTokenFilePath();
|
|
247
|
-
return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start`;
|
|
267
|
+
return `if [ -f ${shellQuote(tokenFile)} ]; then OPENCODEX_API_AUTH_TOKEN="$(cat ${shellQuote(tokenFile)})"; export OPENCODEX_API_AUTH_TOKEN; fi; exec ${shellQuote(bun)} ${shellQuote(cli)} start --port ${port}`;
|
|
248
268
|
}
|
|
249
269
|
|
|
250
270
|
function systemdQuote(value: string): string {
|
|
@@ -316,7 +336,7 @@ function taskXmlString(value: string): string {
|
|
|
316
336
|
.replace(/'/g, "'");
|
|
317
337
|
}
|
|
318
338
|
|
|
319
|
-
export function buildWindowsServiceScript(entry = cliEntry()): string {
|
|
339
|
+
export function buildWindowsServiceScript(entry = cliEntry(), port = resolveServiceListenPort()): string {
|
|
320
340
|
const { bun, cli } = entry;
|
|
321
341
|
const bunRuntime = durableBunRuntime();
|
|
322
342
|
const path = process.env.PATH ?? "";
|
|
@@ -345,7 +365,7 @@ export function buildWindowsServiceScript(entry = cliEntry()): string {
|
|
|
345
365
|
'>>"%OCX_SERVICE_LOG%" echo opencodex_home="%OPENCODEX_HOME%"',
|
|
346
366
|
'>>"%OCX_SERVICE_LOG%" echo codex_home="%CODEX_HOME%"',
|
|
347
367
|
'>>"%OCX_SERVICE_LOG%" echo token_file="%OCX_API_TOKEN_FILE%"',
|
|
348
|
-
|
|
368
|
+
`"%OCX_BUN%" "%OCX_CLI%" start --port ${port} >>"%OCX_SERVICE_LOG%" 2>&1`,
|
|
349
369
|
"if %ERRORLEVEL% NEQ 0 (",
|
|
350
370
|
' >>"%OCX_SERVICE_LOG%" echo [%DATE% %TIME%] child exited with code %ERRORLEVEL%; restarting in 5s',
|
|
351
371
|
// `timeout` needs console stdin and dies with "Input redirection is not supported"
|
package/src/update/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { readFileSync, readdirSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
-
import { getConfigDir, readPid, readRuntimePort } from "../config";
|
|
5
|
+
import { getConfigDir, loadConfig, readPid, readRuntimePort } from "../config";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* A `codex-history-backup-*.json` surviving a stop means the native-history restore was
|
|
@@ -171,6 +171,20 @@ export async function runUpdate(): Promise<void> {
|
|
|
171
171
|
serviceWasInstalled = isServiceInstalled();
|
|
172
172
|
} catch { /* best-effort */ }
|
|
173
173
|
|
|
174
|
+
// Capture listen target before stop clears runtime state (same contract as GUI update worker).
|
|
175
|
+
// Prefer a live runtime record; a stale crashed leftover must not override config.port.
|
|
176
|
+
const preUpdateConfig = loadConfig();
|
|
177
|
+
const preUpdateRt = readRuntimePort();
|
|
178
|
+
const livePid = readPid();
|
|
179
|
+
const runtimeTrusted = !!(preUpdateRt && livePid && preUpdateRt.pid === livePid);
|
|
180
|
+
const configPort = typeof preUpdateConfig.port === "number" && preUpdateConfig.port > 0
|
|
181
|
+
? preUpdateConfig.port
|
|
182
|
+
: 10100;
|
|
183
|
+
const capturedListen = {
|
|
184
|
+
port: runtimeTrusted ? preUpdateRt.port : configPort,
|
|
185
|
+
hostname: (runtimeTrusted ? preUpdateRt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
|
|
186
|
+
};
|
|
187
|
+
|
|
174
188
|
// Never replace package files under a live proxy: the running server dynamic-imports
|
|
175
189
|
// modules after startup, so an in-place update leaves it executing mixed old/new code.
|
|
176
190
|
// Gate on the service and the runtime-port record too, not just the pid file — a
|
|
@@ -230,14 +244,27 @@ export async function runUpdate(): Promise<void> {
|
|
|
230
244
|
if (serviceWasInstalled) {
|
|
231
245
|
console.log("🔁 Reinstalling the background service with the updated files...");
|
|
232
246
|
const { serviceReinstallArgs } = await import("../service");
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
windowsHide: true,
|
|
247
|
+
const { waitForPortAvailable } = await import("../server/ports");
|
|
248
|
+
const freed = await waitForPortAvailable(capturedListen.port, capturedListen.hostname, {
|
|
249
|
+
timeoutMs: 5_000,
|
|
250
|
+
intervalMs: 25,
|
|
238
251
|
});
|
|
239
|
-
if (
|
|
240
|
-
|
|
252
|
+
if (!freed) console.warn(`⚠️ Port ${capturedListen.port} still busy; reinstalling with pinned --port anyway.`);
|
|
253
|
+
const prevBake = process.env.OCX_BAKE_PORT;
|
|
254
|
+
process.env.OCX_BAKE_PORT = String(capturedListen.port);
|
|
255
|
+
try {
|
|
256
|
+
const svcStdio = updateChildStdio();
|
|
257
|
+
const svc = spawnSync(process.execPath, [process.argv[1], ...serviceReinstallArgs()], {
|
|
258
|
+
stdio: svcStdio,
|
|
259
|
+
encoding: svcStdio === "pipe" ? "utf8" : undefined,
|
|
260
|
+
windowsHide: true,
|
|
261
|
+
});
|
|
262
|
+
if (svcStdio === "pipe") logSpawnOutput("", svc);
|
|
263
|
+
if (svc.status !== 0) console.warn("⚠️ Service refresh failed — run 'ocx service install' manually.");
|
|
264
|
+
} finally {
|
|
265
|
+
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
|
|
266
|
+
else process.env.OCX_BAKE_PORT = prevBake;
|
|
267
|
+
}
|
|
241
268
|
} else {
|
|
242
269
|
console.log("Restart the proxy: ocx start");
|
|
243
270
|
}
|
package/src/update/job.ts
CHANGED
|
@@ -291,6 +291,12 @@ export interface RestartIo {
|
|
|
291
291
|
waitForPort?: typeof waitForPortAvailable;
|
|
292
292
|
spawnStart?: (job: UpdateJobState, installer: Installer, port?: number) => void;
|
|
293
293
|
serviceInstalledFn?: () => boolean;
|
|
294
|
+
/** Service-mode install/reinstall command (defaults to spawnSync via runLoggedCommand). */
|
|
295
|
+
runService?: (
|
|
296
|
+
job: UpdateJobState,
|
|
297
|
+
bin: string,
|
|
298
|
+
args: string[],
|
|
299
|
+
) => { status: number | null; signal?: NodeJS.Signals | null };
|
|
294
300
|
}
|
|
295
301
|
|
|
296
302
|
async function restartAfterUpdate(
|
|
@@ -313,10 +319,26 @@ async function restartAfterUpdate(
|
|
|
313
319
|
} catch { /* fallback to default service install */ }
|
|
314
320
|
}
|
|
315
321
|
const cmd = restartCommand(serviceInstalled, job.installer, packageLauncherPath(), port, svcArgs);
|
|
322
|
+
const waitFn = io.waitForPort ?? waitForPortAvailable;
|
|
323
|
+
|
|
316
324
|
if (serviceInstalled) {
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
325
|
+
// Stop-first update already unloaded the service; wait for the socket to drain,
|
|
326
|
+
// then reinstall wrappers that bake `--port` via OCX_BAKE_PORT (PR #152 gap).
|
|
327
|
+
const freed = await waitFn(port, hostname, { timeoutMs: 5_000, intervalMs: 25 });
|
|
328
|
+
if (!freed) {
|
|
329
|
+
updateJob(job, {}, `Port ${port} still busy after stop; reinstalling service with pinned --port ${port} anyway.`);
|
|
330
|
+
}
|
|
331
|
+
const prevBake = process.env.OCX_BAKE_PORT;
|
|
332
|
+
process.env.OCX_BAKE_PORT = String(Math.trunc(port));
|
|
333
|
+
try {
|
|
334
|
+
const run = io.runService ?? ((j, bin, args) => runLoggedCommand(j, bin, args, RESTART_TIMEOUT_MS));
|
|
335
|
+
const result = run(job, cmd.bin, cmd.args);
|
|
336
|
+
if (result.status !== 0) {
|
|
337
|
+
throw new Error(`service restart failed (${cmd.display}, exit ${result.status ?? "?"})`);
|
|
338
|
+
}
|
|
339
|
+
} finally {
|
|
340
|
+
if (prevBake === undefined) delete process.env.OCX_BAKE_PORT;
|
|
341
|
+
else process.env.OCX_BAKE_PORT = prevBake;
|
|
320
342
|
}
|
|
321
343
|
return;
|
|
322
344
|
}
|
|
@@ -329,8 +351,7 @@ async function restartAfterUpdate(
|
|
|
329
351
|
// The old socket can stay busy briefly after stop (Windows taskkill drain, or the
|
|
330
352
|
// stop-first update path that already killed the proxy before we got here) — wait
|
|
331
353
|
// unconditionally on the captured port so the pinned start does not race the drain.
|
|
332
|
-
const
|
|
333
|
-
const freed = await waitFn(port, hostname, { timeoutMs: 2000, intervalMs: 25 });
|
|
354
|
+
const freed = await waitFn(port, hostname, { timeoutMs: 2_000, intervalMs: 25 });
|
|
334
355
|
if (!freed) {
|
|
335
356
|
updateJob(job, {}, `Port ${port} still busy after stop; starting with --port ${port} anyway.`);
|
|
336
357
|
}
|
|
@@ -352,11 +373,17 @@ export async function runGuiUpdateWorker(jobId: string, channel: Channel, restar
|
|
|
352
373
|
const now = new Date().toISOString();
|
|
353
374
|
// Capture the live listen target BEFORE the update command runs: the stop-first update
|
|
354
375
|
// flow clears pid/runtime state, so this is the last moment the real port is knowable.
|
|
376
|
+
// Only trust runtime-port.json when its pid matches the live pidfile process.
|
|
355
377
|
const rt = readRuntimePort();
|
|
378
|
+
const livePid = readPid();
|
|
356
379
|
const preUpdateConfig = loadConfig();
|
|
380
|
+
const runtimeTrusted = !!(rt && livePid && rt.pid === livePid);
|
|
381
|
+
const configPort = typeof preUpdateConfig.port === "number" && preUpdateConfig.port > 0
|
|
382
|
+
? preUpdateConfig.port
|
|
383
|
+
: 10100;
|
|
357
384
|
const captured = {
|
|
358
|
-
port:
|
|
359
|
-
hostname: rt
|
|
385
|
+
port: runtimeTrusted ? rt.port : configPort,
|
|
386
|
+
hostname: (runtimeTrusted ? rt.hostname : undefined) ?? preUpdateConfig.hostname ?? "127.0.0.1",
|
|
360
387
|
};
|
|
361
388
|
if (!job) {
|
|
362
389
|
job = {
|
package/src/usage/log.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
|
|
1
|
+
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, appendFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { getConfigDir } from "../config";
|
|
4
4
|
import { usageDisplayTotalTokens } from "./totals";
|
|
@@ -39,6 +39,14 @@ export interface PersistedUsageEntry {
|
|
|
39
39
|
surface?: "claude";
|
|
40
40
|
resolvedModel?: string;
|
|
41
41
|
requestedModel?: string;
|
|
42
|
+
/** Reasoning effort / service-tier metadata for GUI Logs after restart. */
|
|
43
|
+
requestedEffort?: string;
|
|
44
|
+
requestedServiceTier?: string;
|
|
45
|
+
requestedSpeedLabel?: string;
|
|
46
|
+
configuredServiceTier?: string;
|
|
47
|
+
configuredSpeedLabel?: string;
|
|
48
|
+
modelSupportsServiceTier?: boolean;
|
|
49
|
+
responseServiceTier?: string;
|
|
42
50
|
status: number;
|
|
43
51
|
durationMs: number;
|
|
44
52
|
/** TTFT relative to the request start (WP4); unset for non-streaming/tool-only. */
|
|
@@ -197,6 +205,11 @@ function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] {
|
|
|
197
205
|
.filter((attempt): attempt is PersistedUsageAttempt => attempt !== null);
|
|
198
206
|
}
|
|
199
207
|
|
|
208
|
+
const MAX_METADATA_STRING_LEN = 64;
|
|
209
|
+
function capMetadataString(s: string): string {
|
|
210
|
+
return s.length > MAX_METADATA_STRING_LEN ? s.slice(0, MAX_METADATA_STRING_LEN) : s;
|
|
211
|
+
}
|
|
212
|
+
|
|
200
213
|
function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
201
214
|
const attempts = normalizedAttempts(entry.attempts);
|
|
202
215
|
return {
|
|
@@ -207,6 +220,27 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
|
207
220
|
...(entry.surface === "claude" ? { surface: entry.surface } : {}),
|
|
208
221
|
...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
|
|
209
222
|
...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
|
|
223
|
+
...(typeof entry.requestedEffort === "string" && entry.requestedEffort
|
|
224
|
+
? { requestedEffort: capMetadataString(entry.requestedEffort) }
|
|
225
|
+
: {}),
|
|
226
|
+
...(typeof entry.requestedServiceTier === "string" && entry.requestedServiceTier
|
|
227
|
+
? { requestedServiceTier: capMetadataString(entry.requestedServiceTier) }
|
|
228
|
+
: {}),
|
|
229
|
+
...(typeof entry.requestedSpeedLabel === "string" && entry.requestedSpeedLabel
|
|
230
|
+
? { requestedSpeedLabel: capMetadataString(entry.requestedSpeedLabel) }
|
|
231
|
+
: {}),
|
|
232
|
+
...(typeof entry.configuredServiceTier === "string" && entry.configuredServiceTier
|
|
233
|
+
? { configuredServiceTier: capMetadataString(entry.configuredServiceTier) }
|
|
234
|
+
: {}),
|
|
235
|
+
...(typeof entry.configuredSpeedLabel === "string" && entry.configuredSpeedLabel
|
|
236
|
+
? { configuredSpeedLabel: capMetadataString(entry.configuredSpeedLabel) }
|
|
237
|
+
: {}),
|
|
238
|
+
...(typeof entry.modelSupportsServiceTier === "boolean"
|
|
239
|
+
? { modelSupportsServiceTier: entry.modelSupportsServiceTier }
|
|
240
|
+
: {}),
|
|
241
|
+
...(typeof entry.responseServiceTier === "string" && entry.responseServiceTier
|
|
242
|
+
? { responseServiceTier: capMetadataString(entry.responseServiceTier) }
|
|
243
|
+
: {}),
|
|
210
244
|
status: entry.status,
|
|
211
245
|
durationMs: entry.durationMs,
|
|
212
246
|
...(isNonNegativeFiniteNumber(entry.firstOutputMs)
|
|
@@ -254,3 +288,66 @@ export function readUsageEntries(): PersistedUsageEntry[] {
|
|
|
254
288
|
}
|
|
255
289
|
return entries;
|
|
256
290
|
}
|
|
291
|
+
|
|
292
|
+
function parseUsageLines(lines: string[]): PersistedUsageEntry[] {
|
|
293
|
+
const entries: PersistedUsageEntry[] = [];
|
|
294
|
+
for (const line of lines) {
|
|
295
|
+
if (!line.trim()) continue;
|
|
296
|
+
try {
|
|
297
|
+
const parsed = JSON.parse(line) as PersistedUsageEntry;
|
|
298
|
+
if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") {
|
|
299
|
+
entries.push(normalizeUsageEntry(parsed));
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
/* skip partial / hand-edited lines */
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return entries;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Read only the newest `limit` usage.jsonl rows without loading the whole append-only
|
|
310
|
+
* file into memory. Used by request-log hydration on `ocx start`.
|
|
311
|
+
*/
|
|
312
|
+
export function readRecentUsageEntries(limit: number): PersistedUsageEntry[] {
|
|
313
|
+
if (!Number.isFinite(limit) || limit <= 0) return [];
|
|
314
|
+
const path = usageLogPath();
|
|
315
|
+
if (!existsSync(path)) return [];
|
|
316
|
+
let fd: number | undefined;
|
|
317
|
+
try {
|
|
318
|
+
fd = openSync(path, "r");
|
|
319
|
+
const size = fstatSync(fd).size;
|
|
320
|
+
if (size <= 0) return [];
|
|
321
|
+
// ~4 KiB/row budget with a floor; expand once if the window yields too few lines.
|
|
322
|
+
let windowBytes = Math.min(size, Math.max(64 * 1024, Math.ceil(limit) * 4 * 1024));
|
|
323
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
324
|
+
const start = Math.max(0, size - windowBytes);
|
|
325
|
+
const buf = Buffer.alloc(size - start);
|
|
326
|
+
readSync(fd, buf, 0, buf.length, start);
|
|
327
|
+
let text = buf.toString("utf-8");
|
|
328
|
+
if (start > 0) {
|
|
329
|
+
const nl = text.indexOf("\n");
|
|
330
|
+
if (nl < 0) {
|
|
331
|
+
if (start === 0) break;
|
|
332
|
+
windowBytes = Math.min(size, windowBytes * 4);
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
text = text.slice(nl + 1);
|
|
336
|
+
}
|
|
337
|
+
const lines = text.split(/\r?\n/).filter(line => line.trim());
|
|
338
|
+
// Parse ALL lines first, then take the last N valid entries. This way corrupt
|
|
339
|
+
// or partial lines are filtered out during parsing and we always return the
|
|
340
|
+
// most recent N valid rows (not N physical lines minus corrupt ones).
|
|
341
|
+
const entries = parseUsageLines(lines);
|
|
342
|
+
if (entries.length >= limit || start === 0 || windowBytes >= size) return entries.slice(-limit);
|
|
343
|
+
windowBytes = Math.min(size, windowBytes * 4);
|
|
344
|
+
}
|
|
345
|
+
return [];
|
|
346
|
+
} catch {
|
|
347
|
+
return [];
|
|
348
|
+
} finally {
|
|
349
|
+
if (fd !== undefined) {
|
|
350
|
+
try { closeSync(fd); } catch { /* ignore */ }
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|