@bitkyc08/opencodex 2.7.36 → 2.7.38-preview.20260724
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/gui/dist/assets/{index-ZmFopEYw.js → index-CprFnVjr.js} +8 -8
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +11 -1
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/catalog/bundled.ts +83 -27
- package/src/codex/catalog/effort.ts +95 -3
- package/src/codex/catalog/parsing.ts +17 -0
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/runtime.ts +513 -0
- package/src/config.ts +21 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/responses/parser.ts +1 -0
- package/src/server/index.ts +9 -1
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/shared.ts +6 -6
- package/src/server/management/system-routes.ts +65 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/memory-watchdog.ts +112 -0
- package/src/server/relay-eager.ts +199 -0
- package/src/server/relay.ts +131 -81
- package/src/server/responses/collaboration.ts +20 -3
- package/src/server/responses/core.ts +52 -1
- package/src/types.ts +11 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
package/src/config.ts
CHANGED
|
@@ -442,6 +442,10 @@ const configSchema = z.object({
|
|
|
442
442
|
providerContextCaps: z.record(z.string(), z.number().int().positive()).optional(),
|
|
443
443
|
contextCapValue: z.number().int().positive().optional(),
|
|
444
444
|
multiAgentGuidanceEnabled: z.boolean().optional(),
|
|
445
|
+
// Invalid values degrade to undefined ("auto") instead of failing the whole
|
|
446
|
+
// parse: a hand-edited typo must never trip the backup-and-defaults repair
|
|
447
|
+
// path below and wipe providers/pool accounts. Warning emitted in loadConfig.
|
|
448
|
+
streamMode: z.enum(["auto", "legacy-tee", "eager-relay"]).optional().catch(undefined),
|
|
445
449
|
}).passthrough().superRefine((config, ctx) => {
|
|
446
450
|
for (const name of Object.keys(config.providers)) {
|
|
447
451
|
if (!isValidProviderName(name)) {
|
|
@@ -642,6 +646,19 @@ export function hardenExistingSecret(path: string): void {
|
|
|
642
646
|
}
|
|
643
647
|
}
|
|
644
648
|
}
|
|
649
|
+
/**
|
|
650
|
+
* The schema's `.catch(undefined)` silently degrades an invalid persisted
|
|
651
|
+
* `streamMode` to "auto"; surface that once so a hand-edited typo (e.g.
|
|
652
|
+
* "legacy_tee") is discoverable instead of silently changing stream shape.
|
|
653
|
+
*/
|
|
654
|
+
function warnDegradedStreamMode(rawParsed: unknown, validated: OcxConfig): void {
|
|
655
|
+
if (!rawParsed || typeof rawParsed !== "object") return;
|
|
656
|
+
const raw = (rawParsed as Record<string, unknown>).streamMode;
|
|
657
|
+
if (raw !== undefined && validated.streamMode === undefined) {
|
|
658
|
+
console.warn(`⚠️ config.json streamMode ${JSON.stringify(raw)} is invalid (expected "auto", "legacy-tee", or "eager-relay") — falling back to "auto"`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
645
662
|
export function loadConfig(): OcxConfig {
|
|
646
663
|
const dir = getConfigDir();
|
|
647
664
|
const configPath = getConfigPath();
|
|
@@ -655,7 +672,10 @@ export function loadConfig(): OcxConfig {
|
|
|
655
672
|
const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
|
|
656
673
|
const parsed = JSON.parse(raw);
|
|
657
674
|
const result = configSchema.safeParse(parsed);
|
|
658
|
-
if (result.success)
|
|
675
|
+
if (result.success) {
|
|
676
|
+
warnDegradedStreamMode(parsed, result.data as OcxConfig);
|
|
677
|
+
return result.data as OcxConfig;
|
|
678
|
+
}
|
|
659
679
|
// Schema validation failed — merge defaults into the raw object instead of
|
|
660
680
|
// discarding it entirely, so pool accounts and providers survive a missing
|
|
661
681
|
// field like defaultProvider.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun runtime stream-capability gate for the Windows SSE passthrough path (#314).
|
|
3
|
+
*
|
|
4
|
+
* The eager bounded relay (src/server/relay-eager.ts) uses a JS async producer
|
|
5
|
+
* loop — the exact shape of the Bun#32111 use-after-free (fixed upstream by Bun
|
|
6
|
+
* PR #32120, merged 2026-06-21). No RELEASED Bun version is proven to carry
|
|
7
|
+
* that fix yet, so `MIN_FIXED_BUN_VERSION` is null: every runtime is
|
|
8
|
+
* "known-bad" until a bundle-bump commit sets it. Config `streamMode` can force
|
|
9
|
+
* either path (persisted in config.json because Windows services do not
|
|
10
|
+
* inherit shell env — see devlog/_plan/260723_win_mem_safestream/001).
|
|
11
|
+
*
|
|
12
|
+
* Prerelease conservatism: a version carrying a prerelease suffix (e.g.
|
|
13
|
+
* `1.4.0-canary.3`) is NEVER treated as fixed even when its numeric triple
|
|
14
|
+
* reaches the threshold — canaries are exactly the OPENCODEX_BUN_PATH audience
|
|
15
|
+
* and may predate the fix commit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Bump in the SAME commit that bumps package.json's bundled Bun to a version
|
|
20
|
+
* verified to include Bun PR #32120. null = no released version is known-fixed.
|
|
21
|
+
*/
|
|
22
|
+
export const MIN_FIXED_BUN_VERSION: string | null = null;
|
|
23
|
+
|
|
24
|
+
export type StreamMode = "auto" | "legacy-tee" | "eager-relay";
|
|
25
|
+
|
|
26
|
+
export const STREAM_MODES: readonly StreamMode[] = ["auto", "legacy-tee", "eager-relay"];
|
|
27
|
+
|
|
28
|
+
export function isStreamMode(value: unknown): value is StreamMode {
|
|
29
|
+
return typeof value === "string" && (STREAM_MODES as readonly string[]).includes(value);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Numeric [major, minor, patch] triple, or null for unparseable input. */
|
|
33
|
+
export function parseBunVersion(version: string): [number, number, number] | null {
|
|
34
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
return [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Compare two version strings numerically; null when either is unparseable. */
|
|
40
|
+
export function compareBunVersions(a: string, b: string): number | null {
|
|
41
|
+
const pa = parseBunVersion(a);
|
|
42
|
+
const pb = parseBunVersion(b);
|
|
43
|
+
if (!pa || !pb) return null;
|
|
44
|
+
for (let i = 0; i < 3; i++) {
|
|
45
|
+
if (pa[i]! !== pb[i]!) return pa[i]! - pb[i]!;
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function hasPrereleaseSuffix(version: string): boolean {
|
|
51
|
+
return /^\d+\.\d+\.\d+-/.test(version.trim());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True only when `version` is proven to carry the Bun#32120 async-pull cancel
|
|
56
|
+
* fix. Conservative: unknown, unparseable, prerelease, or no threshold → false.
|
|
57
|
+
*/
|
|
58
|
+
export function bunHasAsyncPullCancelFix(
|
|
59
|
+
version: string,
|
|
60
|
+
minFixed: string | null = MIN_FIXED_BUN_VERSION,
|
|
61
|
+
): boolean {
|
|
62
|
+
if (!minFixed) return false;
|
|
63
|
+
if (hasPrereleaseSuffix(version)) return false;
|
|
64
|
+
const cmp = compareBunVersions(version, minFixed);
|
|
65
|
+
return cmp !== null && cmp >= 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type EagerRelayDecision = {
|
|
69
|
+
useEagerRelay: boolean;
|
|
70
|
+
reason: "config-legacy" | "config-eager" | "auto-fixed-runtime" | "auto-known-bad";
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Decide the win32 SSE client-path shape. `version`/`minFixed` are injectable
|
|
75
|
+
* for tests. Non-win32 callers never consult this (their default path is
|
|
76
|
+
* unchanged); the caller owns the platform check.
|
|
77
|
+
*/
|
|
78
|
+
export function decideEagerRelay(
|
|
79
|
+
mode: StreamMode,
|
|
80
|
+
version: string = Bun.version,
|
|
81
|
+
minFixed: string | null = MIN_FIXED_BUN_VERSION,
|
|
82
|
+
): EagerRelayDecision {
|
|
83
|
+
if (mode === "legacy-tee") return { useEagerRelay: false, reason: "config-legacy" };
|
|
84
|
+
if (mode === "eager-relay") return { useEagerRelay: true, reason: "config-eager" };
|
|
85
|
+
return bunHasAsyncPullCancelFix(version, minFixed)
|
|
86
|
+
? { useEagerRelay: true, reason: "auto-fixed-runtime" }
|
|
87
|
+
: { useEagerRelay: false, reason: "auto-known-bad" };
|
|
88
|
+
}
|
package/src/lib/crash-guard.ts
CHANGED
|
@@ -162,7 +162,9 @@ const BENIGN_LOG_INTERVAL_MS = 5 * 60_000;
|
|
|
162
162
|
* disconnects mid-SSE on the tee()'d passthrough path (responses.ts Bun#32111
|
|
163
163
|
* workaround), Bun's sink-close teardown tries to cancel the tee-locked source body
|
|
164
164
|
* and rejects off-path. Request lifecycle is already settled at that point; same
|
|
165
|
-
* benign handling applies.
|
|
165
|
+
* benign handling applies. The tee path remains the DEFAULT passthrough shape;
|
|
166
|
+
* the gated eager relay (relay-eager.ts, #314) does not tee and may not produce
|
|
167
|
+
* this shape — detection stays unchanged either way.
|
|
166
168
|
*/
|
|
167
169
|
export function isBenignAbortTeardown(err: unknown): boolean {
|
|
168
170
|
if (!(err instanceof TypeError)) return false;
|
package/src/responses/parser.ts
CHANGED
|
@@ -594,6 +594,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
594
594
|
stream: data.stream === true,
|
|
595
595
|
options,
|
|
596
596
|
_rawBody: body,
|
|
597
|
+
...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}),
|
|
597
598
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
598
599
|
...(structuredOutput ? { _structuredOutput: true } : {}),
|
|
599
600
|
...(compactionRequest ? { _compactionRequest: true } : {}),
|
package/src/server/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "../config";
|
|
19
19
|
import { reconcileOAuthProviders } from "../oauth";
|
|
20
20
|
import { invalidateCodexModelsCache } from "../codex/catalog";
|
|
21
|
+
import { startMemoryWatchdog } from "./memory-watchdog";
|
|
21
22
|
import { runOpenAiTierStartupMigration } from "../providers/openai-tier-startup";
|
|
22
23
|
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
23
24
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
@@ -134,8 +135,12 @@ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
|
134
135
|
|
|
135
136
|
// Source invariant for tests/passthrough-abort.test.ts after the pure module split:
|
|
136
137
|
// if (isEventStream && upstreamResponse.body) {
|
|
137
|
-
// upstreamResponse.body.tee()
|
|
138
138
|
// const repairConfig = route.provider.responsesItemIdRepair;
|
|
139
|
+
// #314 gated shape (win32-no-repair only; default OFF on the bundled known-bad runtime):
|
|
140
|
+
// decideEagerRelay(config.streamMode ?? "auto")
|
|
141
|
+
// relaySseEagerBounded(upstreamResponse.body, turnAc,
|
|
142
|
+
// Default shape (tee + background inspection):
|
|
143
|
+
// upstreamResponse.body.tee()
|
|
139
144
|
// const repairedBody = hasResponsesItemIdRepair(repairConfig)
|
|
140
145
|
// process.platform === "win32"
|
|
141
146
|
// && !hasResponsesItemIdRepair(repairConfig)
|
|
@@ -184,6 +189,9 @@ export function startServer(port?: number) {
|
|
|
184
189
|
// usage.jsonl already persists every request; rehydrate the in-memory Logs ring so
|
|
185
190
|
// /api/logs (and the GUI) survive `ocx stop` / `ocx start` process restarts.
|
|
186
191
|
hydrateRequestLogsFromDisk();
|
|
192
|
+
// #314: warn-only RSS observability (unref'd, idempotent — safe under repeated
|
|
193
|
+
// startServer(0) in tests). Snapshot surfaces via GET /api/system/memory.
|
|
194
|
+
startMemoryWatchdog();
|
|
187
195
|
|
|
188
196
|
const listenPort = port ?? config.port ?? 10100;
|
|
189
197
|
setCorsOrigin(listenPort);
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
} from "../../oauth";
|
|
24
24
|
import { removeCredential } from "../../oauth/store";
|
|
25
25
|
import { providerDestinationResolvedError } from "../../lib/destination-policy";
|
|
26
|
+
import { isStreamMode } from "../../lib/bun-stream-caps";
|
|
26
27
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
27
28
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
28
29
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
@@ -58,6 +59,7 @@ import { applySystemEnvToggle } from "../system-env";
|
|
|
58
59
|
import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache";
|
|
59
60
|
import { runWindowsTrayAction } from "../windows-tray-control";
|
|
60
61
|
import { runStartupInstallAction, type StartupInstallAction } from "../startup-action-control";
|
|
62
|
+
import { displayCodexRuntimePath, effortClampAppliesToRuntime, loadLastEffortClamp, resolveCodexRuntime } from "../../codex/runtime";
|
|
61
63
|
|
|
62
64
|
import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
|
|
63
65
|
import type { MetricUnavailableReason, TokPerSecondResult, CostEstimateReason, CostResult, MetricSource } from "./shared";
|
|
@@ -74,11 +76,63 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
74
76
|
}
|
|
75
77
|
|
|
76
78
|
if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
79
|
+
let resolved: ReturnType<typeof resolveCodexRuntime>;
|
|
80
|
+
try {
|
|
81
|
+
// Full alternative discovery (memoized) so newerAvailable warnings work.
|
|
82
|
+
resolved = resolveCodexRuntime();
|
|
83
|
+
} catch {
|
|
84
|
+
resolved = {
|
|
85
|
+
runtime: { command: "codex", version: null, source: "fallback" },
|
|
86
|
+
failures: [],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const lastClamp = loadLastEffortClamp();
|
|
90
|
+
const clampActive = effortClampAppliesToRuntime(lastClamp, resolved.runtime);
|
|
91
|
+
const warningParts: string[] = [];
|
|
92
|
+
if (resolved.replacedConfigured) {
|
|
93
|
+
warningParts.push(
|
|
94
|
+
`Preferred Codex runtime is unavailable; using ${displayCodexRuntimePath(resolved.runtime.command)} instead.`,
|
|
95
|
+
);
|
|
96
|
+
} else if (
|
|
97
|
+
resolved.runtime.source === "fallback"
|
|
98
|
+
&& resolved.failures.length > 0
|
|
99
|
+
&& !resolved.runtime.version
|
|
100
|
+
) {
|
|
101
|
+
warningParts.push("No validated Codex runtime found; falling back to `codex`.");
|
|
102
|
+
}
|
|
103
|
+
if (clampActive) {
|
|
104
|
+
const clampVersion = lastClamp?.runtimeVersion ?? resolved.runtime.version ?? "an older binary";
|
|
105
|
+
warningParts.push(
|
|
106
|
+
`Some reasoning effort options were hidden because OpenCodex used Codex ${clampVersion}.${resolved.newerAvailable ? " A newer Codex installation is available." : ""}`,
|
|
107
|
+
);
|
|
108
|
+
} else if (resolved.newerAvailable) {
|
|
109
|
+
warningParts.push(
|
|
110
|
+
`OpenCodex is using an older Codex binary (${resolved.runtime.version ?? "unknown"}). A newer Codex installation is available.`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
77
113
|
return jsonResponse({
|
|
78
114
|
codexAutoStart: codexAutoStartEnabled(config),
|
|
79
115
|
port: config.port,
|
|
80
116
|
hostname: config.hostname ?? "127.0.0.1",
|
|
117
|
+
streamMode: config.streamMode ?? "auto",
|
|
81
118
|
startupHealth: await getCachedStartupHealth(config),
|
|
119
|
+
codexRuntime: {
|
|
120
|
+
path: displayCodexRuntimePath(resolved.runtime.command),
|
|
121
|
+
version: resolved.runtime.version,
|
|
122
|
+
source: resolved.runtime.source,
|
|
123
|
+
newerAvailable: resolved.newerAvailable
|
|
124
|
+
? {
|
|
125
|
+
path: displayCodexRuntimePath(resolved.newerAvailable.command),
|
|
126
|
+
version: resolved.newerAvailable.version,
|
|
127
|
+
}
|
|
128
|
+
: null,
|
|
129
|
+
catalogClamp: {
|
|
130
|
+
active: clampActive,
|
|
131
|
+
removedEfforts: clampActive ? (lastClamp?.removedEfforts ?? []) : [],
|
|
132
|
+
runtimeVersion: clampActive ? (lastClamp?.runtimeVersion ?? null) : null,
|
|
133
|
+
},
|
|
134
|
+
warning: warningParts.length > 0 ? warningParts.join(" ") : null,
|
|
135
|
+
},
|
|
82
136
|
});
|
|
83
137
|
}
|
|
84
138
|
|
|
@@ -127,17 +181,39 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise<Respon
|
|
|
127
181
|
}
|
|
128
182
|
|
|
129
183
|
if (url.pathname === "/api/settings" && req.method === "PUT") {
|
|
130
|
-
|
|
184
|
+
// Each field is optional but at least one must be present; fields are
|
|
185
|
+
// validated when present. streamMode-only PUTs must work: Windows-memory
|
|
186
|
+
// troubleshooting docs tell service users to set it here (a service does
|
|
187
|
+
// not inherit shell env, so config.json is its only input). A stream-shape
|
|
188
|
+
// change applies to NEW turns only — the config object is shared by
|
|
189
|
+
// reference with the request handlers, no restart needed.
|
|
190
|
+
let body: { codexAutoStart?: unknown; streamMode?: unknown };
|
|
131
191
|
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
132
|
-
if (
|
|
192
|
+
if (body.codexAutoStart === undefined && body.streamMode === undefined) {
|
|
133
193
|
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
134
194
|
}
|
|
135
|
-
|
|
195
|
+
if (body.codexAutoStart !== undefined && typeof body.codexAutoStart !== "boolean") {
|
|
196
|
+
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
197
|
+
}
|
|
198
|
+
if (body.streamMode !== undefined && !isStreamMode(body.streamMode)) {
|
|
199
|
+
return jsonResponse({ error: "streamMode must be auto, legacy-tee, or eager-relay" }, 400);
|
|
200
|
+
}
|
|
201
|
+
if (typeof body.codexAutoStart === "boolean") {
|
|
202
|
+
config.codexAutoStart = body.codexAutoStart;
|
|
203
|
+
}
|
|
204
|
+
if (body.streamMode !== undefined) {
|
|
205
|
+
if (body.streamMode === "auto") {
|
|
206
|
+
delete config.streamMode;
|
|
207
|
+
} else {
|
|
208
|
+
config.streamMode = body.streamMode as "legacy-tee" | "eager-relay";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
136
211
|
saveConfig(config);
|
|
137
212
|
invalidateStartupHealthCache();
|
|
138
213
|
return jsonResponse({
|
|
139
214
|
ok: true,
|
|
140
215
|
codexAutoStart: codexAutoStartEnabled(config),
|
|
216
|
+
streamMode: config.streamMode ?? "auto",
|
|
141
217
|
startupHealth: await getCachedStartupHealth(config),
|
|
142
218
|
});
|
|
143
219
|
}
|
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../../types";
|
|
52
52
|
import { drainAndShutdown } from "../lifecycle";
|
|
53
53
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
54
|
-
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
54
|
+
import { estimateComboCost, estimateRequestCost, effectiveServiceTier, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
55
55
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
56
56
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
57
57
|
import { applySystemEnvToggle } from "../system-env";
|
|
@@ -88,7 +88,7 @@ export type CostResult =
|
|
|
88
88
|
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
89
89
|
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
90
90
|
|
|
91
|
-
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage"> & {
|
|
91
|
+
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier"> & {
|
|
92
92
|
attempts?: readonly PersistedUsageAttempt[];
|
|
93
93
|
};
|
|
94
94
|
|
|
@@ -124,9 +124,10 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
export function costResult(entry: MetricSource): CostResult {
|
|
127
|
+
const tier = effectiveServiceTier(entry);
|
|
127
128
|
const estimate = entry.attempts?.length
|
|
128
|
-
? estimateComboCost(entry.attempts)
|
|
129
|
-
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus });
|
|
129
|
+
? estimateComboCost(entry.attempts, undefined, tier)
|
|
130
|
+
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
|
|
130
131
|
if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
|
|
131
132
|
const estimateReasons = [
|
|
132
133
|
entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
|
|
@@ -152,7 +153,7 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
|
152
153
|
...attempt,
|
|
153
154
|
displayMetrics: {
|
|
154
155
|
tokPerSecond: tokPerSecondResult(attempt),
|
|
155
|
-
cost: costResult({ ...attempt, attempts: undefined }),
|
|
156
|
+
cost: costResult({ ...attempt, attempts: undefined, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
|
|
156
157
|
},
|
|
157
158
|
})),
|
|
158
159
|
}
|
|
@@ -183,4 +184,3 @@ export function stripRegistryOnlyStaticHeaders(name: string, provider: OcxProvid
|
|
|
183
184
|
return rest;
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /api/system/* — service-process runtime/memory introspection (#314 WP3).
|
|
3
|
+
*
|
|
4
|
+
* Rides the standard management gate: every /api/* request already passed
|
|
5
|
+
* requireApiAuth("management") + the origin check before dispatch, so these
|
|
6
|
+
* routes add no auth of their own. NEVER expose this data on the
|
|
7
|
+
* unauthenticated /healthz surface.
|
|
8
|
+
*
|
|
9
|
+
* The payload is scalar-only (numbers, enum strings): no paths, no tokens, no
|
|
10
|
+
* account identifiers. `jscHeap` (bun:jsc heapStats) is the js-vs-native
|
|
11
|
+
* discriminator: a flat JS heap under a growing RSS points at native runtime
|
|
12
|
+
* memory (the #314 shape), not an app-level JS leak.
|
|
13
|
+
*/
|
|
14
|
+
import { decideEagerRelay } from "../../lib/bun-stream-caps";
|
|
15
|
+
import { getActiveMemoryWatchdog } from "../memory-watchdog";
|
|
16
|
+
import { jsonResponse } from "../auth-cors";
|
|
17
|
+
import type { ManagementContext } from "./context";
|
|
18
|
+
|
|
19
|
+
const ENDPOINT_SAMPLE_LIMIT = 60;
|
|
20
|
+
|
|
21
|
+
export async function handleSystemRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
22
|
+
const { req, url, config } = ctx;
|
|
23
|
+
if (url.pathname === "/api/system/memory" && req.method === "GET") {
|
|
24
|
+
const usage = process.memoryUsage();
|
|
25
|
+
let jscHeap: { heapSize: number; heapCapacity: number; objectCount: number } | null = null;
|
|
26
|
+
try {
|
|
27
|
+
const { heapStats } = await import("bun:jsc");
|
|
28
|
+
const stats = heapStats();
|
|
29
|
+
jscHeap = {
|
|
30
|
+
heapSize: stats.heapSize,
|
|
31
|
+
heapCapacity: stats.heapCapacity,
|
|
32
|
+
objectCount: stats.objectCount,
|
|
33
|
+
};
|
|
34
|
+
} catch {
|
|
35
|
+
/* non-Bun tooling or unavailable introspection — omit the discriminator */
|
|
36
|
+
}
|
|
37
|
+
const watchdogInstance = getActiveMemoryWatchdog();
|
|
38
|
+
const watchdog = watchdogInstance
|
|
39
|
+
? (() => {
|
|
40
|
+
const snap = watchdogInstance.snapshot();
|
|
41
|
+
return {
|
|
42
|
+
warnThresholdBytes: snap.warnThresholdBytes,
|
|
43
|
+
lastWarnAt: snap.lastWarnAt,
|
|
44
|
+
samples: snap.samples.slice(-ENDPOINT_SAMPLE_LIMIT),
|
|
45
|
+
};
|
|
46
|
+
})()
|
|
47
|
+
: null;
|
|
48
|
+
const streamMode = config.streamMode ?? "auto";
|
|
49
|
+
return jsonResponse({
|
|
50
|
+
pid: process.pid,
|
|
51
|
+
bunVersion: Bun.version,
|
|
52
|
+
bunRevision: Bun.revision,
|
|
53
|
+
platform: process.platform,
|
|
54
|
+
uptimeSeconds: process.uptime(),
|
|
55
|
+
rss: usage.rss,
|
|
56
|
+
heapUsed: usage.heapUsed,
|
|
57
|
+
heapTotal: usage.heapTotal,
|
|
58
|
+
jscHeap,
|
|
59
|
+
streamMode,
|
|
60
|
+
eagerRelay: process.platform === "win32" ? decideEagerRelay(streamMode) : null,
|
|
61
|
+
watchdog,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
@@ -64,6 +64,7 @@ import { handleModelRoutes } from "./management/model-routes";
|
|
|
64
64
|
import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
65
65
|
import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
66
66
|
import { handleComboRoutes } from "./management/combo-routes";
|
|
67
|
+
import { handleSystemRoutes } from "./management/system-routes";
|
|
67
68
|
import type { ManagementContext } from "./management/context";
|
|
68
69
|
export type { ManagementApiDeps } from "./management/context";
|
|
69
70
|
import { fetchAllModels } from "./management/shared";
|
|
@@ -128,7 +129,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
128
129
|
?? (await handleModelRoutes(ctx))
|
|
129
130
|
?? (await handleAgentSettingsRoutes(ctx))
|
|
130
131
|
?? (await handleOauthAccountRoutes(ctx))
|
|
131
|
-
?? (await handleComboRoutes(ctx))
|
|
132
|
+
?? (await handleComboRoutes(ctx))
|
|
133
|
+
?? (await handleSystemRoutes(ctx));
|
|
132
134
|
if (routed) return routed;
|
|
133
135
|
|
|
134
136
|
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RSS memory watchdog (#314 WP3) — warn-only observability for the Windows
|
|
3
|
+
* native-memory growth reported upstream (Bun fetch buffers / socket handles).
|
|
4
|
+
*
|
|
5
|
+
* Samples process.memoryUsage() on an unref'd interval into a bounded ring and
|
|
6
|
+
* logs ONE rate-limited warning when RSS crosses the threshold. It never
|
|
7
|
+
* restarts anything (threshold auto-restart is deliberately deferred; the
|
|
8
|
+
* service managers' crash-respawn already covers hard failures). The active
|
|
9
|
+
* instance is a module-level singleton so the management API can expose the
|
|
10
|
+
* snapshot without threading server state through route contexts.
|
|
11
|
+
*
|
|
12
|
+
* Privacy: samples are scalar numbers only; the warn line never interpolates
|
|
13
|
+
* paths, hostnames, or tokens.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export type MemorySample = {
|
|
17
|
+
/** Epoch ms. */
|
|
18
|
+
at: number;
|
|
19
|
+
/** Resident set size in bytes. */
|
|
20
|
+
rss: number;
|
|
21
|
+
/** JS heap used in bytes (process.memoryUsage().heapUsed). */
|
|
22
|
+
heapUsed: number;
|
|
23
|
+
/** JS heap total in bytes. */
|
|
24
|
+
heapTotal: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type MemoryWatchdogState = {
|
|
28
|
+
samples: MemorySample[];
|
|
29
|
+
warnThresholdBytes: number;
|
|
30
|
+
lastWarnAt: number | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MemoryWatchdog = {
|
|
34
|
+
stop(): void;
|
|
35
|
+
snapshot(): MemoryWatchdogState;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const DEFAULT_INTERVAL_MS = 60_000;
|
|
39
|
+
const DEFAULT_WARN_THRESHOLD_BYTES = 4 * 1024 ** 3; // 4 GiB
|
|
40
|
+
const DEFAULT_RING_SIZE = 360; // ≈6h at 60s
|
|
41
|
+
const WARN_INTERVAL_MS = 30 * 60_000;
|
|
42
|
+
const DOCS_URL = "https://lidge-jun.github.io/opencodex/troubleshooting/windows-memory/";
|
|
43
|
+
|
|
44
|
+
let active: MemoryWatchdog | null = null;
|
|
45
|
+
|
|
46
|
+
/** The running watchdog, if any — read by /api/system/memory. */
|
|
47
|
+
export function getActiveMemoryWatchdog(): MemoryWatchdog | null {
|
|
48
|
+
return active;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function defaultSample(now: () => number): MemorySample {
|
|
52
|
+
const usage = process.memoryUsage();
|
|
53
|
+
return { at: now(), rss: usage.rss, heapUsed: usage.heapUsed, heapTotal: usage.heapTotal };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Start (or replace) the process-wide memory watchdog. Idempotent: a previous
|
|
58
|
+
* active instance is stopped first, so repeated startServer() calls in tests
|
|
59
|
+
* never accumulate intervals. The timer is unref'd; stop() is exposed for
|
|
60
|
+
* tests and clears the singleton.
|
|
61
|
+
*/
|
|
62
|
+
export function startMemoryWatchdog(opts?: {
|
|
63
|
+
intervalMs?: number;
|
|
64
|
+
warnThresholdBytes?: number;
|
|
65
|
+
ringSize?: number;
|
|
66
|
+
now?: () => number;
|
|
67
|
+
sample?: () => MemorySample;
|
|
68
|
+
warn?: (msg: string) => void;
|
|
69
|
+
}): MemoryWatchdog {
|
|
70
|
+
active?.stop();
|
|
71
|
+
const intervalMs = opts?.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
72
|
+
const warnThresholdBytes = opts?.warnThresholdBytes ?? DEFAULT_WARN_THRESHOLD_BYTES;
|
|
73
|
+
const ringSize = opts?.ringSize ?? DEFAULT_RING_SIZE;
|
|
74
|
+
const now = opts?.now ?? Date.now;
|
|
75
|
+
const sample = opts?.sample ?? (() => defaultSample(now));
|
|
76
|
+
const warn = opts?.warn ?? ((msg: string) => console.warn(msg));
|
|
77
|
+
|
|
78
|
+
const samples: MemorySample[] = [];
|
|
79
|
+
let lastWarnAt: number | null = null;
|
|
80
|
+
|
|
81
|
+
const tick = () => {
|
|
82
|
+
let s: MemorySample;
|
|
83
|
+
try {
|
|
84
|
+
s = sample();
|
|
85
|
+
} catch {
|
|
86
|
+
return; // sampling must never break the server
|
|
87
|
+
}
|
|
88
|
+
samples.push(s);
|
|
89
|
+
if (samples.length > ringSize) samples.splice(0, samples.length - ringSize);
|
|
90
|
+
if (s.rss >= warnThresholdBytes && (lastWarnAt === null || now() - lastWarnAt >= WARN_INTERVAL_MS)) {
|
|
91
|
+
lastWarnAt = now();
|
|
92
|
+
const rssMb = Math.round(s.rss / (1024 * 1024));
|
|
93
|
+
const thresholdMb = Math.round(warnThresholdBytes / (1024 * 1024));
|
|
94
|
+
warn(`⚠️ opencodex RSS ${rssMb}MB exceeds the ${thresholdMb}MB watch threshold. On Windows this is usually the upstream Bun runtime memory issue — see ${DOCS_URL}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const timer = setInterval(tick, intervalMs);
|
|
99
|
+
(timer as { unref?: () => void }).unref?.();
|
|
100
|
+
|
|
101
|
+
const instance: MemoryWatchdog = {
|
|
102
|
+
stop() {
|
|
103
|
+
clearInterval(timer);
|
|
104
|
+
if (active === instance) active = null;
|
|
105
|
+
},
|
|
106
|
+
snapshot() {
|
|
107
|
+
return { samples: [...samples], warnThresholdBytes, lastWarnAt };
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
active = instance;
|
|
111
|
+
return instance;
|
|
112
|
+
}
|