@bitkyc08/opencodex 2.7.36 → 2.7.37
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.ja.md +8 -1
- package/README.ko.md +7 -1
- package/README.md +7 -1
- package/README.ru.md +7 -1
- package/README.zh-CN.md +7 -1
- package/gui/dist/assets/index-BhUTxmCy.js +52 -0
- package/gui/dist/assets/index-oOZcqVmj.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +7 -0
- package/src/adapters/cursor/message-mapper.ts +3 -0
- package/src/adapters/cursor/protobuf-request.ts +223 -27
- package/src/adapters/cursor/request-builder.ts +41 -15
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +3 -1
- package/src/adapters/cursor.ts +44 -9
- package/src/adapters/google.ts +115 -62
- package/src/adapters/kiro.ts +3 -17
- package/src/adapters/openai-chat.ts +16 -5
- package/src/adapters/openai-responses.ts +56 -1
- package/src/adapters/run-turn-queue.ts +11 -1
- package/src/bridge.ts +139 -69
- package/src/chat/outbound.ts +135 -73
- package/src/cli/codex-shim-autorestore.ts +45 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/index.ts +17 -3
- package/src/cli/status.ts +80 -0
- package/src/cli/v2.ts +14 -2
- package/src/codex/auth-context.ts +18 -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/catalog/provider-fetch.ts +31 -8
- package/src/codex/exec-invocation.ts +22 -0
- package/src/codex/model-cache.ts +44 -0
- package/src/codex/runtime.ts +529 -0
- package/src/codex/shim.ts +608 -10
- package/src/combos/resolve.ts +7 -2
- package/src/config.ts +32 -1
- package/src/lib/bun-stream-caps.ts +88 -0
- package/src/lib/crash-guard.ts +3 -1
- package/src/lib/sse-decoder.ts +25 -6
- package/src/responses/parser.ts +2 -1
- package/src/responses/state.ts +10 -2
- package/src/server/auth-cors.ts +4 -1
- package/src/server/index.ts +191 -1
- package/src/server/live.ts +491 -0
- package/src/server/management/config-routes.ts +79 -3
- package/src/server/management/provider-routes.ts +2 -0
- 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 +236 -21
- package/src/server/responses/encrypted-payload.ts +118 -41
- package/src/server/ws-bridge.ts +7 -0
- package/src/types.ts +25 -0
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +19 -0
- package/src/usage/summary.ts +11 -8
- package/gui/dist/assets/index-BpX-hoSd.css +0 -1
- package/gui/dist/assets/index-ZmFopEYw.js +0 -52
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eager bounded single-reader SSE relay (#314 mitigation, WP2).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the tee()+background-inspection passthrough shape on runtimes where
|
|
5
|
+
* the Bun#32111 async-pull cancel fix is present (src/lib/bun-stream-caps.ts):
|
|
6
|
+
* ONE eager producer loop reads upstream, feeds every chunk through the shared
|
|
7
|
+
* SSE inspector (terminal outcome, quota, request log, context cache), and
|
|
8
|
+
* enqueues it into a byte-bounded client queue. When the queue is full the
|
|
9
|
+
* producer pauses — no unbounded tee branch queue can build up behind a slow
|
|
10
|
+
* client.
|
|
11
|
+
*
|
|
12
|
+
* Honesty caveats (audit M5): full leak relief additionally assumes the
|
|
13
|
+
* runtime carries the Bun#29831 fetch receive-backpressure fix and that Bun's
|
|
14
|
+
* native Response sink pull-paces a JS ReadableStream. Neither is provable in
|
|
15
|
+
* bun:test (a JS reader always paces); both remain "awaiting Windows user
|
|
16
|
+
* verification".
|
|
17
|
+
*
|
|
18
|
+
* #44 cancel semantics: after client cancel the relay keeps reading upstream in
|
|
19
|
+
* DISCARD-DRAIN mode (inspection only) until a terminal is seen or the bounded
|
|
20
|
+
* drain window (ms/bytes) expires — a genuinely reached terminal records as
|
|
21
|
+
* completed/failed, never downgraded to cancel. Only when no terminal arrives
|
|
22
|
+
* within bounds does onClientCancel fire. This bounds today's unbounded tee
|
|
23
|
+
* drain; the tradeoff is that client-cancel log finalization may be delayed by
|
|
24
|
+
* up to the drain window.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export type EagerRelayHooks = {
|
|
28
|
+
/** Feed one upstream chunk through SSE inspection (createSseInspector.feed). */
|
|
29
|
+
inspectChunk: (chunk: Uint8Array) => void;
|
|
30
|
+
/** Flush inspection at upstream end (createSseInspector.finish). */
|
|
31
|
+
finishInspection: () => void;
|
|
32
|
+
/** True once inspection has reported a protocol terminal (inspector.reported). */
|
|
33
|
+
sawTerminal: () => boolean;
|
|
34
|
+
/** Record a synthetic terminal (caller decides incomplete vs failed-502). */
|
|
35
|
+
onSynthetic: (kind: "incomplete" | "failed") => void;
|
|
36
|
+
/** Client cancelled and NO terminal arrived within the drain bounds. */
|
|
37
|
+
onClientCancel: () => void;
|
|
38
|
+
/** Exactly once, after the producer fully stops (unregisterTurn parity). */
|
|
39
|
+
onDone: () => void;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type EagerRelayOptions = {
|
|
43
|
+
/** Bounded client queue in bytes; producer pauses above it. Default 8 MiB. */
|
|
44
|
+
maxQueueBytes?: number;
|
|
45
|
+
/** Post-cancel discard-drain wall-clock bound. Default 15 000 ms. */
|
|
46
|
+
postCancelDrainMs?: number;
|
|
47
|
+
/** Post-cancel discard-drain byte bound. Default 32 MiB. */
|
|
48
|
+
postCancelDrainBytes?: number;
|
|
49
|
+
/** Injectable clock for tests. */
|
|
50
|
+
now?: () => number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const DEFAULT_MAX_QUEUE_BYTES = 8 * 1024 * 1024;
|
|
54
|
+
const DEFAULT_DRAIN_MS = 15_000;
|
|
55
|
+
const DEFAULT_DRAIN_BYTES = 32 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Relay `body` to the returned stream with eager bounded reading and inline
|
|
59
|
+
* inspection. `upstream` is aborted on cancel-drain expiry and observed for
|
|
60
|
+
* shutdown teardown (its abort wakes a paused producer and suppresses
|
|
61
|
+
* synthetic terminals — audit M3).
|
|
62
|
+
*/
|
|
63
|
+
export function relaySseEagerBounded(
|
|
64
|
+
body: ReadableStream<Uint8Array>,
|
|
65
|
+
upstream: AbortController,
|
|
66
|
+
hooks: EagerRelayHooks,
|
|
67
|
+
opts?: EagerRelayOptions,
|
|
68
|
+
): ReadableStream<Uint8Array> {
|
|
69
|
+
const maxQueueBytes = opts?.maxQueueBytes ?? DEFAULT_MAX_QUEUE_BYTES;
|
|
70
|
+
const drainMs = opts?.postCancelDrainMs ?? DEFAULT_DRAIN_MS;
|
|
71
|
+
const drainBytes = opts?.postCancelDrainBytes ?? DEFAULT_DRAIN_BYTES;
|
|
72
|
+
const now = opts?.now ?? Date.now;
|
|
73
|
+
|
|
74
|
+
const reader = body.getReader();
|
|
75
|
+
let queuedBytes = 0;
|
|
76
|
+
let cancelled = false;
|
|
77
|
+
let done = false;
|
|
78
|
+
// Pause gate: resolved by client pull, client cancel, or upstream abort so a
|
|
79
|
+
// paused producer ALWAYS resumes (audit blocker 2 — no deadlock; onDone and
|
|
80
|
+
// turn unregistration stay reachable, drainAndShutdown never hangs).
|
|
81
|
+
let wake: (() => void) | null = null;
|
|
82
|
+
const wakeUp = () => { const w = wake; wake = null; w?.(); };
|
|
83
|
+
const paused = () => new Promise<void>(resolve => { wake = resolve; });
|
|
84
|
+
upstream.signal.addEventListener("abort", wakeUp, { once: true });
|
|
85
|
+
|
|
86
|
+
let controllerRef: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
87
|
+
let doneFired = false;
|
|
88
|
+
let drainTimer: ReturnType<typeof setTimeout> | null = null;
|
|
89
|
+
const fireDone = () => {
|
|
90
|
+
if (doneFired) return;
|
|
91
|
+
doneFired = true;
|
|
92
|
+
if (drainTimer) { clearTimeout(drainTimer); drainTimer = null; }
|
|
93
|
+
try { hooks.onDone(); } catch { /* lifecycle callbacks must not break teardown */ }
|
|
94
|
+
};
|
|
95
|
+
// A silent upstream after cancel would park the drain loop in reader.read();
|
|
96
|
+
// the wall-clock bound must fire regardless, so cancel arms a hard timer that
|
|
97
|
+
// aborts upstream at the deadline (the abort wakes the read).
|
|
98
|
+
const armDrainTimer = () => {
|
|
99
|
+
if (drainTimer) return;
|
|
100
|
+
drainTimer = setTimeout(() => {
|
|
101
|
+
drainTimer = null;
|
|
102
|
+
upstream.abort(new Error("post-cancel drain window expired"));
|
|
103
|
+
}, drainMs);
|
|
104
|
+
(drainTimer as { unref?: () => void }).unref?.();
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const producer = async () => {
|
|
108
|
+
let syntheticKind: "incomplete" | "failed" | null = null;
|
|
109
|
+
// reader.read() is not intrinsically tied to the upstream AbortController
|
|
110
|
+
// (a fetch body usually rejects on abort, but that coupling is the fetch
|
|
111
|
+
// implementation's, not the stream's). Race every read against the abort
|
|
112
|
+
// signal so cancel-drain expiry and shutdown teardown ALWAYS break the
|
|
113
|
+
// loop even on a silent upstream.
|
|
114
|
+
const aborted: Promise<"aborted"> = new Promise(resolve => {
|
|
115
|
+
if (upstream.signal.aborted) resolve("aborted");
|
|
116
|
+
else upstream.signal.addEventListener("abort", () => resolve("aborted"), { once: true });
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
for (;;) {
|
|
120
|
+
const result = await Promise.race([reader.read(), aborted]);
|
|
121
|
+
if (result === "aborted") break;
|
|
122
|
+
const { done: upstreamDone, value } = result;
|
|
123
|
+
if (upstreamDone) {
|
|
124
|
+
hooks.finishInspection();
|
|
125
|
+
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
|
|
126
|
+
syntheticKind = "incomplete";
|
|
127
|
+
}
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
hooks.inspectChunk(value);
|
|
131
|
+
if (cancelled) {
|
|
132
|
+
// Discard-drain: inspection only, nothing queued. Stop at terminal
|
|
133
|
+
// or when the bounded window expires.
|
|
134
|
+
drainedBytes += value.byteLength;
|
|
135
|
+
if (hooks.sawTerminal() || drainedBytes >= drainBytes || now() >= drainDeadline) {
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
queuedBytes += value.byteLength;
|
|
141
|
+
try {
|
|
142
|
+
controllerRef?.enqueue(value);
|
|
143
|
+
} catch {
|
|
144
|
+
// Controller already torn down (client went away without cancel()).
|
|
145
|
+
cancelled = true;
|
|
146
|
+
drainDeadline = now() + drainMs;
|
|
147
|
+
armDrainTimer();
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
while (queuedBytes > maxQueueBytes && !cancelled && !upstream.signal.aborted) {
|
|
151
|
+
await paused();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// Upstream read failure. Distinguish genuine mid-stream reset from
|
|
156
|
+
// abort-driven teardown (shutdown/cancel-expiry) — audit M3.
|
|
157
|
+
if (!hooks.sawTerminal() && !cancelled && !upstream.signal.aborted) {
|
|
158
|
+
syntheticKind = "failed";
|
|
159
|
+
try { controllerRef?.error(new Error("upstream stream failed")); } catch { /* torn down */ }
|
|
160
|
+
}
|
|
161
|
+
} finally {
|
|
162
|
+
if (syntheticKind) hooks.onSynthetic(syntheticKind);
|
|
163
|
+
if (cancelled && !hooks.sawTerminal()) {
|
|
164
|
+
hooks.onClientCancel();
|
|
165
|
+
}
|
|
166
|
+
if (cancelled || upstream.signal.aborted) {
|
|
167
|
+
upstream.abort();
|
|
168
|
+
reader.cancel().catch(() => {});
|
|
169
|
+
}
|
|
170
|
+
if (!cancelled) {
|
|
171
|
+
try { controllerRef?.close(); } catch { /* already closed/errored */ }
|
|
172
|
+
}
|
|
173
|
+
fireDone();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let drainedBytes = 0;
|
|
178
|
+
let drainDeadline = Number.POSITIVE_INFINITY;
|
|
179
|
+
|
|
180
|
+
return new ReadableStream<Uint8Array>({
|
|
181
|
+
start(controller) {
|
|
182
|
+
controllerRef = controller;
|
|
183
|
+
void producer();
|
|
184
|
+
},
|
|
185
|
+
pull() {
|
|
186
|
+
// The client consumed from the queue; approximate accounting: reset on
|
|
187
|
+
// pull below cap. desiredSize reflects internal queue in chunks, not
|
|
188
|
+
// bytes, so we track bytes ourselves and drain optimistically.
|
|
189
|
+
queuedBytes = 0;
|
|
190
|
+
wakeUp();
|
|
191
|
+
},
|
|
192
|
+
cancel() {
|
|
193
|
+
cancelled = true;
|
|
194
|
+
drainDeadline = now() + drainMs;
|
|
195
|
+
armDrainTimer();
|
|
196
|
+
wakeUp();
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
}
|
package/src/server/relay.ts
CHANGED
|
@@ -159,15 +159,22 @@ export function completedResponseFromSsePayload(payload: string): { id?: unknown
|
|
|
159
159
|
if (payload === "[DONE]") return null;
|
|
160
160
|
try {
|
|
161
161
|
const json = JSON.parse(payload) as { type?: unknown; response?: unknown };
|
|
162
|
-
|
|
163
|
-
const response = json.response;
|
|
164
|
-
if (!response || typeof response !== "object" || Array.isArray(response)) return null;
|
|
165
|
-
return response as { id?: unknown; output?: unknown; status?: unknown };
|
|
162
|
+
return completedResponseFromParsedEvent(json);
|
|
166
163
|
} catch {
|
|
167
164
|
return null;
|
|
168
165
|
}
|
|
169
166
|
}
|
|
170
167
|
|
|
168
|
+
/** Extract the response object from an already-parsed `response.completed` event, or null. */
|
|
169
|
+
function completedResponseFromParsedEvent(
|
|
170
|
+
json: { type?: unknown; response?: unknown } | null,
|
|
171
|
+
): { id?: unknown; output?: unknown; status?: unknown } | null {
|
|
172
|
+
if (!json || json.type !== "response.completed") return null;
|
|
173
|
+
const response = json.response;
|
|
174
|
+
if (!response || typeof response !== "object" || Array.isArray(response)) return null;
|
|
175
|
+
return response as { id?: unknown; output?: unknown; status?: unknown };
|
|
176
|
+
}
|
|
177
|
+
|
|
171
178
|
export function trackSseForRequestLog(
|
|
172
179
|
body: ReadableStream<Uint8Array>,
|
|
173
180
|
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
@@ -404,6 +411,116 @@ export function relaySseWithHeartbeat(
|
|
|
404
411
|
* Background-consume an SSE stream purely for terminal-outcome inspection (quota tracking).
|
|
405
412
|
* Does not produce output; safe to ignore errors (the client-facing stream is separate).
|
|
406
413
|
*/
|
|
414
|
+
export type SseInspector = {
|
|
415
|
+
/** Feed one upstream chunk through the SSE scanning state machine. */
|
|
416
|
+
feed(chunk: Uint8Array): void;
|
|
417
|
+
/** Flush the decoder + trailing unterminated buffer (upstream cleanly done). */
|
|
418
|
+
finish(): void;
|
|
419
|
+
/** True once a protocol terminal was detected and reported. */
|
|
420
|
+
reported(): boolean;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Per-chunk SSE inspection state machine shared by consumeForInspection,
|
|
425
|
+
* consumeForResponseLogMetadata, and the eager bounded relay (relay-eager.ts).
|
|
426
|
+
*
|
|
427
|
+
* Extraction-fidelity invariants (devlog/_plan/260723_win_mem_safestream/020):
|
|
428
|
+
* - logCtx SSE inspection is gated on !reported; in the metadata configuration
|
|
429
|
+
* (no onTerminal) `reported` stays permanently false, which reproduces the
|
|
430
|
+
* metadata consumer's unconditional inspection through the same gate.
|
|
431
|
+
* - finish() skips the trailing-buffer scan once reported, while per-block
|
|
432
|
+
* onCompletedResponse continues firing after reported — an intentional
|
|
433
|
+
* asymmetry inherited from consumeForInspection.
|
|
434
|
+
* - logCtx.transportPhase/terminalSource are mutated BEFORE onTerminal fires.
|
|
435
|
+
* - Synthetic terminals (incomplete / failed-502) are the CALLER's decision:
|
|
436
|
+
* the caller owns `cancelled` state and reads `reported()` to decide.
|
|
437
|
+
*/
|
|
438
|
+
export function createSseInspector(handlers: {
|
|
439
|
+
onTerminal?: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void;
|
|
440
|
+
logCtx?: RequestLogContext;
|
|
441
|
+
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void;
|
|
442
|
+
onFirstOutput?: () => void;
|
|
443
|
+
}): SseInspector {
|
|
444
|
+
const decoder = new TextDecoder();
|
|
445
|
+
let buffer = "";
|
|
446
|
+
let reported = false;
|
|
447
|
+
const reportFirstOutput = createFirstOutputReporter(handlers.onFirstOutput);
|
|
448
|
+
// Allocate reconstruction state only for persistence-capable inspectors.
|
|
449
|
+
const completedItemsByOutputIndex = handlers.onCompletedResponse
|
|
450
|
+
? new Map<number, unknown>()
|
|
451
|
+
: null;
|
|
452
|
+
|
|
453
|
+
const scanPayload = (payload: string | null): void => {
|
|
454
|
+
if (!reported && handlers.logCtx) inspectResponseLogSsePayload(handlers.logCtx, payload);
|
|
455
|
+
reportFirstOutput(payload);
|
|
456
|
+
if (!payload) return;
|
|
457
|
+
if (!reported && handlers.onTerminal) {
|
|
458
|
+
const status = terminalStatusFromSsePayload(payload);
|
|
459
|
+
if (status) {
|
|
460
|
+
reported = true;
|
|
461
|
+
if (handlers.logCtx) {
|
|
462
|
+
handlers.logCtx.transportPhase = "terminal_sse";
|
|
463
|
+
handlers.logCtx.terminalSource = "upstream";
|
|
464
|
+
}
|
|
465
|
+
handlers.onTerminal(status);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (handlers.onCompletedResponse) {
|
|
469
|
+
type ParsedSseEvent = { type?: unknown; output_index?: unknown; item?: unknown; response?: unknown };
|
|
470
|
+
let parsedEvent: ParsedSseEvent | null = null;
|
|
471
|
+
try {
|
|
472
|
+
if (payload !== "[DONE]") parsedEvent = JSON.parse(payload) as ParsedSseEvent;
|
|
473
|
+
} catch {
|
|
474
|
+
/* malformed SSE payloads remain best-effort/no-throw */
|
|
475
|
+
}
|
|
476
|
+
const doneItem = parsedEvent?.type === "response.output_item.done" ? parsedEvent.item : undefined;
|
|
477
|
+
if (parsedEvent
|
|
478
|
+
&& doneItem !== undefined
|
|
479
|
+
&& Number.isInteger(parsedEvent.output_index)
|
|
480
|
+
&& (parsedEvent.output_index as number) >= 0
|
|
481
|
+
&& typeof doneItem === "object"
|
|
482
|
+
&& doneItem !== null
|
|
483
|
+
&& !Array.isArray(doneItem)
|
|
484
|
+
&& typeof (doneItem as { type?: unknown }).type === "string") {
|
|
485
|
+
completedItemsByOutputIndex!.set(parsedEvent.output_index as number, doneItem);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
let response = completedResponseFromParsedEvent(parsedEvent);
|
|
489
|
+
if (response
|
|
490
|
+
&& (!Array.isArray(response.output) || response.output.length === 0)
|
|
491
|
+
&& completedItemsByOutputIndex!.size > 0) {
|
|
492
|
+
response = {
|
|
493
|
+
...response,
|
|
494
|
+
output: [...completedItemsByOutputIndex!.entries()]
|
|
495
|
+
.sort(([left], [right]) => left - right)
|
|
496
|
+
.map(([, item]) => item),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
if (response) handlers.onCompletedResponse(response);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
feed(chunk) {
|
|
505
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
506
|
+
let next: { block: string; rest: string } | null;
|
|
507
|
+
while ((next = nextSseBlock(buffer))) {
|
|
508
|
+
buffer = next.rest;
|
|
509
|
+
if (reported && !handlers.onCompletedResponse) continue;
|
|
510
|
+
scanPayload(sseDataPayload(next.block));
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
finish() {
|
|
514
|
+
buffer += decoder.decode();
|
|
515
|
+
if (buffer.trim() && !reported) {
|
|
516
|
+
scanPayload(sseDataPayload(buffer));
|
|
517
|
+
}
|
|
518
|
+
buffer = "";
|
|
519
|
+
},
|
|
520
|
+
reported: () => reported,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
407
524
|
export function consumeForInspection(
|
|
408
525
|
body: ReadableStream<Uint8Array>,
|
|
409
526
|
onTerminal: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void,
|
|
@@ -415,11 +532,8 @@ export function consumeForInspection(
|
|
|
415
532
|
onFirstOutput?: () => void,
|
|
416
533
|
): void {
|
|
417
534
|
const reader = body.getReader();
|
|
418
|
-
const
|
|
419
|
-
let buffer = "";
|
|
420
|
-
let reported = false;
|
|
535
|
+
const inspector = createSseInspector({ onTerminal, logCtx, onCompletedResponse, onFirstOutput });
|
|
421
536
|
let cancelled = false;
|
|
422
|
-
const reportFirstOutput = createFirstOutputReporter(onFirstOutput);
|
|
423
537
|
if (signal) {
|
|
424
538
|
if (signal.aborted) {
|
|
425
539
|
// Aborted before we could read anything (Codex disconnects the instant it finishes reading).
|
|
@@ -444,64 +558,20 @@ export function consumeForInspection(
|
|
|
444
558
|
for (;;) {
|
|
445
559
|
const { done, value } = await reader.read();
|
|
446
560
|
if (done) {
|
|
447
|
-
|
|
448
|
-
if (
|
|
449
|
-
const payload = sseDataPayload(buffer);
|
|
450
|
-
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
451
|
-
reportFirstOutput(payload);
|
|
452
|
-
if (payload) {
|
|
453
|
-
const status = terminalStatusFromSsePayload(payload);
|
|
454
|
-
if (status) {
|
|
455
|
-
reported = true;
|
|
456
|
-
if (logCtx) {
|
|
457
|
-
logCtx.transportPhase = "terminal_sse";
|
|
458
|
-
logCtx.terminalSource = "upstream";
|
|
459
|
-
}
|
|
460
|
-
onTerminal(status);
|
|
461
|
-
}
|
|
462
|
-
if (onCompletedResponse) {
|
|
463
|
-
const response = completedResponseFromSsePayload(payload);
|
|
464
|
-
if (response) onCompletedResponse(response);
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
if (!reported && !cancelled) {
|
|
561
|
+
inspector.finish();
|
|
562
|
+
if (!inspector.reported() && !cancelled) {
|
|
469
563
|
if (logCtx) logCtx.terminalSource = "synthetic";
|
|
470
564
|
onTerminal("incomplete");
|
|
471
565
|
}
|
|
472
566
|
return;
|
|
473
567
|
}
|
|
474
|
-
|
|
475
|
-
let next: { block: string; rest: string } | null;
|
|
476
|
-
while ((next = nextSseBlock(buffer))) {
|
|
477
|
-
buffer = next.rest;
|
|
478
|
-
if (reported && !onCompletedResponse) continue;
|
|
479
|
-
const payload = sseDataPayload(next.block);
|
|
480
|
-
if (!reported && logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
481
|
-
reportFirstOutput(payload);
|
|
482
|
-
if (!payload) continue;
|
|
483
|
-
if (!reported) {
|
|
484
|
-
const status = terminalStatusFromSsePayload(payload);
|
|
485
|
-
if (status) {
|
|
486
|
-
reported = true;
|
|
487
|
-
if (logCtx) {
|
|
488
|
-
logCtx.transportPhase = "terminal_sse";
|
|
489
|
-
logCtx.terminalSource = "upstream";
|
|
490
|
-
}
|
|
491
|
-
onTerminal(status);
|
|
492
|
-
}
|
|
493
|
-
}
|
|
494
|
-
if (onCompletedResponse) {
|
|
495
|
-
const response = completedResponseFromSsePayload(payload);
|
|
496
|
-
if (response) onCompletedResponse(response);
|
|
497
|
-
}
|
|
498
|
-
}
|
|
568
|
+
inspector.feed(value);
|
|
499
569
|
}
|
|
500
570
|
} catch {
|
|
501
571
|
// Upstream read failure after HTTP 200 (mid-stream socket reset) is not a
|
|
502
572
|
// protocol `response.incomplete` terminal. Report a synthetic 502 so account
|
|
503
573
|
// health treats it as transient; abort-driven client cancellation still wins.
|
|
504
|
-
if (!reported && !cancelled) {
|
|
574
|
+
if (!inspector.reported() && !cancelled) {
|
|
505
575
|
if (logCtx) {
|
|
506
576
|
logCtx.transportPhase = "mid_stream";
|
|
507
577
|
logCtx.terminalSource = "synthetic";
|
|
@@ -524,9 +594,9 @@ export function consumeForResponseLogMetadata(
|
|
|
524
594
|
onFirstOutput?: () => void,
|
|
525
595
|
): void {
|
|
526
596
|
const reader = body.getReader();
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const
|
|
597
|
+
// No onTerminal → the inspector's `reported` gate stays permanently false,
|
|
598
|
+
// reproducing this consumer's unconditional logCtx inspection.
|
|
599
|
+
const inspector = createSseInspector({ logCtx, onCompletedResponse, onFirstOutput });
|
|
530
600
|
if (signal) {
|
|
531
601
|
if (signal.aborted) {
|
|
532
602
|
reader.cancel(signal.reason).catch(() => {});
|
|
@@ -542,30 +612,10 @@ export function consumeForResponseLogMetadata(
|
|
|
542
612
|
for (;;) {
|
|
543
613
|
const { done, value } = await reader.read();
|
|
544
614
|
if (done) {
|
|
545
|
-
|
|
546
|
-
if (buffer.trim()) {
|
|
547
|
-
const payload = sseDataPayload(buffer);
|
|
548
|
-
inspectResponseLogSsePayload(logCtx, payload);
|
|
549
|
-
reportFirstOutput(payload);
|
|
550
|
-
if (payload && onCompletedResponse) {
|
|
551
|
-
const response = completedResponseFromSsePayload(payload);
|
|
552
|
-
if (response) onCompletedResponse(response);
|
|
553
|
-
}
|
|
554
|
-
}
|
|
615
|
+
inspector.finish();
|
|
555
616
|
return;
|
|
556
617
|
}
|
|
557
|
-
|
|
558
|
-
let next: { block: string; rest: string } | null;
|
|
559
|
-
while ((next = nextSseBlock(buffer))) {
|
|
560
|
-
buffer = next.rest;
|
|
561
|
-
const payload = sseDataPayload(next.block);
|
|
562
|
-
inspectResponseLogSsePayload(logCtx, payload);
|
|
563
|
-
reportFirstOutput(payload);
|
|
564
|
-
if (payload && onCompletedResponse) {
|
|
565
|
-
const response = completedResponseFromSsePayload(payload);
|
|
566
|
-
if (response) onCompletedResponse(response);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
618
|
+
inspector.feed(value);
|
|
569
619
|
}
|
|
570
620
|
} catch {
|
|
571
621
|
/* metadata inspection must not affect the client-facing stream */
|