@bitkyc08/opencodex 2.8.0 → 2.9.0
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 +24 -0
- package/bin/ocx.mjs +32 -4
- package/gui/dist/assets/index-CHwf3tTD.css +1 -0
- package/gui/dist/assets/index-u5eFOv2y.js +67 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-normalize.ts +114 -20
- package/src/adapters/anthropic.ts +126 -10
- package/src/adapters/azure.ts +3 -3
- package/src/adapters/base.ts +7 -3
- package/src/adapters/cursor/discovery.ts +14 -4
- package/src/adapters/cursor/effort-map.ts +18 -6
- package/src/adapters/cursor/framing.ts +102 -27
- package/src/adapters/cursor/kv-store.ts +30 -3
- package/src/adapters/cursor/live-models.ts +22 -2
- package/src/adapters/cursor/live-transport.ts +245 -49
- package/src/adapters/cursor/mcp-manager.ts +105 -8
- package/src/adapters/cursor/native-exec-mcp.ts +5 -3
- package/src/adapters/cursor/native-exec-shell.ts +296 -14
- package/src/adapters/cursor/native-exec.ts +381 -33
- package/src/adapters/cursor/protobuf-events.ts +28 -1
- package/src/adapters/cursor/protobuf-request.ts +71 -39
- package/src/adapters/cursor/request-builder.ts +2 -2
- package/src/adapters/cursor/transport.ts +2 -0
- package/src/adapters/cursor.ts +13 -2
- package/src/adapters/google-antigravity-replay.ts +184 -17
- package/src/adapters/google.ts +58 -8
- package/src/adapters/kiro-thinking.ts +23 -9
- package/src/adapters/kiro-tools.ts +49 -18
- package/src/adapters/kiro.ts +377 -133
- package/src/adapters/mimo-free.ts +36 -4
- package/src/adapters/openai-chat.ts +143 -17
- package/src/adapters/openai-responses.ts +130 -14
- package/src/adapters/run-turn-queue.ts +7 -1
- package/src/bridge.ts +466 -69
- package/src/chat/outbound.ts +144 -38
- package/src/claude/inbound-debug.ts +53 -8
- package/src/claude/outbound.ts +224 -38
- package/src/cli/agent-driven.ts +34 -1
- package/src/cli/catalog-prewarm.ts +5 -2
- package/src/cli/claude-desktop.ts +2 -2
- package/src/cli/doctor.ts +12 -0
- package/src/cli/export-command.ts +187 -0
- package/src/cli/help.ts +11 -0
- package/src/cli/index.ts +13 -3
- package/src/cli/init.ts +129 -102
- package/src/cli/opencode.ts +36 -151
- package/src/cli/star-prompt.ts +13 -4
- package/src/cli/status-oauth.ts +12 -2
- package/src/clients/config-export.ts +377 -0
- package/src/codex/account-runtime-state.ts +19 -1
- package/src/codex/account-store.ts +162 -82
- package/src/codex/auth-api.ts +467 -159
- package/src/codex/auth-context.ts +15 -2
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/effort.ts +16 -6
- package/src/codex/catalog/metadata.ts +6 -0
- package/src/codex/catalog/parsing.ts +3 -1
- package/src/codex/catalog/provider-fetch.ts +29 -0
- package/src/codex/catalog/sync.ts +64 -7
- package/src/codex/catalog.ts +2 -2
- package/src/codex/inject.ts +5 -5
- package/src/codex/main-account-cache.ts +8 -1
- package/src/codex/model-cache.ts +81 -2
- package/src/codex/pool-rotation.ts +39 -0
- package/src/codex/project-config-warnings.ts +12 -1
- package/src/codex/quota.ts +35 -3
- package/src/codex/routing.ts +46 -1
- package/src/codex/shim.ts +10 -4
- package/src/codex/subagent-model-fallback.ts +12 -0
- package/src/codex/websocket-registry.ts +27 -0
- package/src/combos/failover.ts +31 -1
- package/src/combos/request.ts +9 -0
- package/src/combos/resolve.ts +60 -4
- package/src/combos/types.ts +12 -0
- package/src/config.ts +510 -55
- package/src/github/star-state.ts +13 -1
- package/src/images/fulfill.ts +39 -1
- package/src/images/loop.ts +52 -12
- package/src/lib/admission.ts +83 -0
- package/src/lib/app-owned-memory-stores.ts +173 -0
- package/src/lib/app-owned-memory.ts +265 -0
- package/src/lib/bun-stream-caps.ts +31 -7
- package/src/lib/config-ownership.ts +33 -0
- package/src/lib/crash-guard.ts +65 -5
- package/src/lib/debug-log-buffer.ts +47 -6
- package/src/lib/destination-policy.ts +12 -1
- package/src/lib/errors.ts +3 -0
- package/src/lib/gcp-adc.ts +40 -2
- package/src/lib/injection-debug-log.ts +26 -2
- package/src/lib/provider-outbound.ts +3 -0
- package/src/lib/sidecar-tracker.ts +5 -2
- package/src/lib/sse-decoder.ts +257 -37
- package/src/lib/state-store-registrations.ts +109 -0
- package/src/lib/state-store-sweeper.ts +184 -0
- package/src/lib/translator-budget.ts +356 -0
- package/src/lib/windows-secret-acl.ts +33 -12
- package/src/lib/winsw.ts +14 -1
- package/src/oauth/anthropic-routing.ts +31 -7
- package/src/oauth/google-antigravity.ts +2 -1
- package/src/oauth/health.ts +30 -12
- package/src/oauth/index.ts +127 -23
- package/src/oauth/kiro-credentials.ts +72 -1
- package/src/oauth/kiro.ts +23 -4
- package/src/oauth/store.ts +165 -18
- package/src/oauth/token-guardian.ts +43 -4
- package/src/oauth/types.ts +2 -1
- package/src/providers/base-url-choices.ts +10 -0
- package/src/providers/derive.ts +12 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/key-failover.ts +12 -0
- package/src/providers/openai-sidecar.ts +4 -1
- package/src/providers/quota.ts +68 -7
- package/src/providers/registry.ts +279 -3
- package/src/responses/parser.ts +5 -1
- package/src/responses/spill-store.ts +394 -0
- package/src/responses/state.ts +520 -102
- package/src/router.ts +18 -1
- package/src/server/adapter-resolve.ts +20 -3
- package/src/server/auth-cors.ts +121 -28
- package/src/server/chat-completions.ts +57 -12
- package/src/server/claude-messages.ts +85 -13
- package/src/server/index.ts +242 -100
- package/src/server/lifecycle.ts +155 -25
- package/src/server/management/agent-settings-routes.ts +79 -36
- package/src/server/management/api-key-usage.ts +167 -0
- package/src/server/management/body.ts +35 -0
- package/src/server/management/combo-routes.ts +5 -1
- package/src/server/management/config-routes.ts +42 -12
- package/src/server/management/logs-usage-routes.ts +41 -21
- package/src/server/management/model-routes.ts +188 -54
- package/src/server/management/oauth-account-routes.ts +115 -26
- package/src/server/management/provider-routes.ts +56 -6
- package/src/server/management/shared.ts +16 -3
- package/src/server/management/sidebar-routes.ts +50 -1
- package/src/server/management/system-restart.ts +13 -6
- package/src/server/management/system-routes.ts +15 -3
- package/src/server/management/usage-summary-cache.ts +86 -0
- package/src/server/management-api.ts +39 -5
- package/src/server/management-auth.ts +65 -14
- package/src/server/port-reclaim.ts +58 -12
- package/src/server/ports.ts +2 -0
- package/src/server/proxy-liveness.ts +60 -14
- package/src/server/relay-eager.ts +20 -4
- package/src/server/relay.ts +548 -154
- package/src/server/request-decompress.ts +51 -4
- package/src/server/request-log.ts +134 -15
- package/src/server/responses/collaboration.ts +15 -4
- package/src/server/responses/compact.ts +3 -0
- package/src/server/responses/core.ts +241 -66
- package/src/server/responses-image-gen-repair.ts +19 -5
- package/src/server/responses-item-id-repair.ts +23 -5
- package/src/server/sse-payload-rewrite.ts +71 -12
- package/src/server/startup-health-cache.ts +14 -1
- package/src/server/system-env.ts +8 -1
- package/src/server/windows-tcp-drop.ts +15 -5
- package/src/server/ws-bridge.ts +25 -0
- package/src/service.ts +179 -13
- package/src/storage/policy-job.ts +93 -23
- package/src/storage/policy-worker.ts +6 -0
- package/src/storage/restore-job.ts +62 -16
- package/src/storage/restore-worker.ts +6 -0
- package/src/storage/storage-mutation-coordinator.ts +36 -6
- package/src/storage/worker-lifecycle.ts +181 -47
- package/src/tray/windows.ts +97 -25
- package/src/types.ts +39 -6
- package/src/update/index.ts +24 -5
- package/src/update/job.ts +598 -73
- package/src/usage/log.ts +115 -17
- package/src/usage/summary.ts +67 -2
- package/src/vision/index.ts +112 -22
- package/src/web-search/loop.ts +38 -6
- package/src/web-search/progress-stream.ts +14 -3
- package/gui/dist/assets/index-BDjpkcRN.js +0 -67
- package/gui/dist/assets/index-BHsKRFh9.css +0 -1
package/src/server/relay.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResponsesTerminalStatus } from "../bridge";
|
|
2
|
+
import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
|
|
2
3
|
import { isUsageDebugEnabled } from "../usage/debug";
|
|
3
4
|
import {
|
|
4
5
|
addRequestLog,
|
|
@@ -6,16 +7,52 @@ import {
|
|
|
6
7
|
httpStatusForRequestLogTerminal,
|
|
7
8
|
inspectResponseLogJson,
|
|
8
9
|
inspectResponseLogSsePayload,
|
|
10
|
+
inspectResponseLogSsePayloadParsed,
|
|
9
11
|
recordFirstOutput,
|
|
10
12
|
type RequestLogContext,
|
|
11
13
|
type RequestLogEntry,
|
|
12
14
|
} from "./request-log";
|
|
13
15
|
|
|
14
16
|
const nativePassthroughSseResponses = new WeakSet<Response>();
|
|
17
|
+
const eagerRelaySseResponses = new WeakSet<Response>();
|
|
18
|
+
|
|
19
|
+
export const MAX_INSPECTION_SSE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
20
|
+
export const MAX_COMPLETED_OUTPUT_ITEMS = 256;
|
|
21
|
+
export const MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES = 8 * 1024 * 1024;
|
|
22
|
+
export const MAX_TAIL_ERROR_MESSAGE_CHARS = 512;
|
|
23
|
+
|
|
24
|
+
export type InspectionCounters = {
|
|
25
|
+
frameBufferHighWaterBytes: number;
|
|
26
|
+
completedItemsMaxCount: number;
|
|
27
|
+
frameCapOverflows: number;
|
|
28
|
+
itemCapEvictions: number;
|
|
29
|
+
postCancelDrainStops: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const inspectionCounters: InspectionCounters = {
|
|
33
|
+
frameBufferHighWaterBytes: 0,
|
|
34
|
+
completedItemsMaxCount: 0,
|
|
35
|
+
frameCapOverflows: 0,
|
|
36
|
+
itemCapEvictions: 0,
|
|
37
|
+
postCancelDrainStops: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function getInspectionCounters(): InspectionCounters {
|
|
41
|
+
return { ...inspectionCounters };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resetInspectionCountersForTest(): void {
|
|
45
|
+
inspectionCounters.frameBufferHighWaterBytes = 0;
|
|
46
|
+
inspectionCounters.completedItemsMaxCount = 0;
|
|
47
|
+
inspectionCounters.frameCapOverflows = 0;
|
|
48
|
+
inspectionCounters.itemCapEvictions = 0;
|
|
49
|
+
inspectionCounters.postCancelDrainStops = 0;
|
|
50
|
+
}
|
|
15
51
|
|
|
16
52
|
export function relayWithAbort(
|
|
17
53
|
body: ReadableStream<Uint8Array> | null,
|
|
18
54
|
upstream: AbortController,
|
|
55
|
+
onClientGone?: (reason?: unknown) => void,
|
|
19
56
|
): ReadableStream<Uint8Array> | null {
|
|
20
57
|
if (!body) return null;
|
|
21
58
|
const reader = body.getReader();
|
|
@@ -33,13 +70,31 @@ export function relayWithAbort(
|
|
|
33
70
|
}
|
|
34
71
|
},
|
|
35
72
|
cancel(reason) {
|
|
36
|
-
//
|
|
37
|
-
|
|
73
|
+
// A tee caller may transfer abort ownership to its bounded inspection pump.
|
|
74
|
+
if (onClientGone) onClientGone(reason);
|
|
75
|
+
else upstream.abort(reason);
|
|
38
76
|
reader.cancel(reason).catch(() => {});
|
|
39
77
|
},
|
|
40
78
|
});
|
|
41
79
|
}
|
|
42
80
|
|
|
81
|
+
export function buildFailedTailPayload(err: unknown): string {
|
|
82
|
+
const translatorOverflow = isTranslatorBudgetExceededError(err);
|
|
83
|
+
const message = (translatorOverflow
|
|
84
|
+
? "upstream translation buffer exceeded the safe limit"
|
|
85
|
+
: `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`)
|
|
86
|
+
.slice(0, MAX_TAIL_ERROR_MESSAGE_CHARS);
|
|
87
|
+
const failure = {
|
|
88
|
+
type: "upstream_error",
|
|
89
|
+
code: translatorOverflow ? "translation_buffer_limit" : "upstream_reset",
|
|
90
|
+
message,
|
|
91
|
+
};
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
type: "response.failed",
|
|
94
|
+
response: { status: "failed", error: failure, last_error: failure },
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
43
98
|
/**
|
|
44
99
|
* Relay a passthrough SSE body like relayWithAbort, but convert a MID-STREAM failure (upstream
|
|
45
100
|
* reset after headers) into a clean terminal: any partial block is closed off, then a synthetic
|
|
@@ -51,6 +106,7 @@ export function relayWithAbort(
|
|
|
51
106
|
export function relaySseWithFailedTail(
|
|
52
107
|
body: ReadableStream<Uint8Array>,
|
|
53
108
|
upstream: AbortController,
|
|
109
|
+
onClientGone?: (reason?: unknown) => void,
|
|
54
110
|
): ReadableStream<Uint8Array> {
|
|
55
111
|
const reader = body.getReader();
|
|
56
112
|
const encoder = new TextEncoder();
|
|
@@ -64,15 +120,7 @@ export function relaySseWithFailedTail(
|
|
|
64
120
|
}
|
|
65
121
|
controller.enqueue(value);
|
|
66
122
|
} catch (err) {
|
|
67
|
-
const
|
|
68
|
-
type: "upstream_error",
|
|
69
|
-
code: "upstream_reset",
|
|
70
|
-
message: `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
|
|
71
|
-
};
|
|
72
|
-
const payload = JSON.stringify({
|
|
73
|
-
type: "response.failed",
|
|
74
|
-
response: { status: "failed", error: failure, last_error: failure },
|
|
75
|
-
});
|
|
123
|
+
const payload = buildFailedTailPayload(err);
|
|
76
124
|
try {
|
|
77
125
|
// Leading blank line terminates a partial SSE block so the failed frame parses cleanly.
|
|
78
126
|
controller.enqueue(encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\ndata: [DONE]\n\n`));
|
|
@@ -82,7 +130,8 @@ export function relaySseWithFailedTail(
|
|
|
82
130
|
}
|
|
83
131
|
},
|
|
84
132
|
cancel(reason) {
|
|
85
|
-
|
|
133
|
+
if (onClientGone) onClientGone(reason);
|
|
134
|
+
else upstream.abort(reason);
|
|
86
135
|
reader.cancel(reason).catch(() => {});
|
|
87
136
|
},
|
|
88
137
|
});
|
|
@@ -108,49 +157,61 @@ export function sseDataPayload(block: string): string | null {
|
|
|
108
157
|
}
|
|
109
158
|
|
|
110
159
|
export function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null {
|
|
111
|
-
|
|
160
|
+
if (payload === "[DONE]") return null;
|
|
161
|
+
try {
|
|
162
|
+
return terminalStatusFromParsed(JSON.parse(payload));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
112
166
|
}
|
|
113
167
|
|
|
114
168
|
/** True when a native Responses SSE payload carries the FIRST kind of non-empty model output. */
|
|
115
169
|
export function isFirstOutputSsePayload(payload: string | null): boolean {
|
|
116
170
|
if (!payload || payload === "[DONE]") return false;
|
|
117
171
|
try {
|
|
118
|
-
|
|
119
|
-
return (event.type === "response.output_text.delta"
|
|
120
|
-
|| event.type === "response.reasoning_summary_text.delta"
|
|
121
|
-
|| event.type === "response.reasoning_text.delta")
|
|
122
|
-
&& typeof event.delta === "string"
|
|
123
|
-
&& event.delta.length > 0;
|
|
172
|
+
return firstOutputFromParsed(JSON.parse(payload));
|
|
124
173
|
} catch {
|
|
125
174
|
return false;
|
|
126
175
|
}
|
|
127
176
|
}
|
|
128
177
|
|
|
129
|
-
function
|
|
178
|
+
export function firstOutputFromParsed(parsed: unknown): boolean {
|
|
179
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return false;
|
|
180
|
+
const event = parsed as { type?: unknown; delta?: unknown };
|
|
181
|
+
return (event.type === "response.output_text.delta"
|
|
182
|
+
|| event.type === "response.reasoning_summary_text.delta"
|
|
183
|
+
|| event.type === "response.reasoning_text.delta")
|
|
184
|
+
&& typeof event.delta === "string"
|
|
185
|
+
&& event.delta.length > 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function createFirstOutputReporter(onFirstOutput?: () => void): {
|
|
189
|
+
payload: (payload: string | null) => void;
|
|
190
|
+
parsed: (parsed: unknown) => void;
|
|
191
|
+
} {
|
|
130
192
|
let reported = false;
|
|
131
|
-
|
|
132
|
-
if (reported || !
|
|
193
|
+
const report = (isFirst: boolean) => {
|
|
194
|
+
if (reported || !isFirst) return;
|
|
133
195
|
reported = true;
|
|
134
196
|
try { onFirstOutput?.(); } catch { /* metrics must not break the stream */ }
|
|
135
197
|
};
|
|
198
|
+
return {
|
|
199
|
+
payload: payload => report(isFirstOutputSsePayload(payload)),
|
|
200
|
+
parsed: parsed => report(firstOutputFromParsed(parsed)),
|
|
201
|
+
};
|
|
136
202
|
}
|
|
137
203
|
|
|
138
|
-
function
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
default:
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
} catch {
|
|
153
|
-
return null;
|
|
204
|
+
export function terminalStatusFromParsed(parsed: unknown): ResponsesTerminalStatus | null {
|
|
205
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
206
|
+
switch ((parsed as { type?: unknown }).type) {
|
|
207
|
+
case "response.completed":
|
|
208
|
+
return "completed";
|
|
209
|
+
case "response.failed":
|
|
210
|
+
return "failed";
|
|
211
|
+
case "response.incomplete":
|
|
212
|
+
return "incomplete";
|
|
213
|
+
default:
|
|
214
|
+
return null;
|
|
154
215
|
}
|
|
155
216
|
}
|
|
156
217
|
|
|
@@ -166,11 +227,12 @@ export function completedResponseFromSsePayload(payload: string): { id?: unknown
|
|
|
166
227
|
}
|
|
167
228
|
|
|
168
229
|
/** Extract the response object from an already-parsed `response.completed` event, or null. */
|
|
169
|
-
function completedResponseFromParsedEvent(
|
|
170
|
-
json:
|
|
230
|
+
export function completedResponseFromParsedEvent(
|
|
231
|
+
json: unknown,
|
|
171
232
|
): { id?: unknown; output?: unknown; status?: unknown } | null {
|
|
172
|
-
if (!json || json
|
|
173
|
-
|
|
233
|
+
if (!json || typeof json !== "object" || Array.isArray(json)
|
|
234
|
+
|| (json as { type?: unknown }).type !== "response.completed") return null;
|
|
235
|
+
const response = (json as { response?: unknown }).response;
|
|
174
236
|
if (!response || typeof response !== "object" || Array.isArray(response)) return null;
|
|
175
237
|
return response as { id?: unknown; output?: unknown; status?: unknown };
|
|
176
238
|
}
|
|
@@ -197,7 +259,7 @@ export function trackSseForRequestLog(
|
|
|
197
259
|
const inspectPayload = (payload: string | null) => {
|
|
198
260
|
if (!payload) return;
|
|
199
261
|
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
200
|
-
reportFirstOutput(payload);
|
|
262
|
+
reportFirstOutput.payload(payload);
|
|
201
263
|
const status = terminalStatusFromSsePayload(payload);
|
|
202
264
|
if (status) reportTerminal(status);
|
|
203
265
|
};
|
|
@@ -321,6 +383,16 @@ export function isNativePassthroughSseResponse(response: Response): boolean {
|
|
|
321
383
|
return nativePassthroughSseResponses.has(response);
|
|
322
384
|
}
|
|
323
385
|
|
|
386
|
+
export function markEagerRelaySseResponse(response: Response): Response {
|
|
387
|
+
eagerRelaySseResponses.add(response);
|
|
388
|
+
return response;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Test-only path identity seam; runtime behavior must not branch on this marker. */
|
|
392
|
+
export function isEagerRelaySseResponse(response: Response): boolean {
|
|
393
|
+
return eagerRelaySseResponses.has(response);
|
|
394
|
+
}
|
|
395
|
+
|
|
324
396
|
export function relaySseWithHeartbeat(
|
|
325
397
|
body: ReadableStream<Uint8Array> | null,
|
|
326
398
|
upstream: AbortController,
|
|
@@ -416,10 +488,59 @@ export type SseInspector = {
|
|
|
416
488
|
feed(chunk: Uint8Array): void;
|
|
417
489
|
/** Flush the decoder + trailing unterminated buffer (upstream cleanly done). */
|
|
418
490
|
finish(): void;
|
|
491
|
+
/** Drop every retained frame/item reference without parsing. Idempotent. */
|
|
492
|
+
dispose(): void;
|
|
419
493
|
/** True once a protocol terminal was detected and reported. */
|
|
420
494
|
reported(): boolean;
|
|
495
|
+
/** True once any protocol terminal was parsed, including metadata-only inspectors. */
|
|
496
|
+
terminalSeen(): boolean;
|
|
421
497
|
};
|
|
422
498
|
|
|
499
|
+
export type SseInspectorHandlers = {
|
|
500
|
+
onTerminal?: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void;
|
|
501
|
+
logCtx?: RequestLogContext;
|
|
502
|
+
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void;
|
|
503
|
+
onFirstOutput?: () => void;
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
type CompletedOutputItem = { item: unknown; sourceBytes: number };
|
|
507
|
+
|
|
508
|
+
function delimiterLengthAt(
|
|
509
|
+
index: number,
|
|
510
|
+
length: number,
|
|
511
|
+
byteAt: (index: number) => number,
|
|
512
|
+
): number | 0 | undefined {
|
|
513
|
+
const first = byteAt(index);
|
|
514
|
+
if (first === 10) {
|
|
515
|
+
if (index + 1 >= length) return undefined;
|
|
516
|
+
const second = byteAt(index + 1);
|
|
517
|
+
if (second === 10) return 2;
|
|
518
|
+
if (second !== 13) return 0;
|
|
519
|
+
if (index + 2 >= length) return undefined;
|
|
520
|
+
return byteAt(index + 2) === 10 ? 3 : 0;
|
|
521
|
+
}
|
|
522
|
+
if (first !== 13) return 0;
|
|
523
|
+
if (index + 1 >= length) return undefined;
|
|
524
|
+
if (byteAt(index + 1) !== 10) return 0;
|
|
525
|
+
if (index + 2 >= length) return undefined;
|
|
526
|
+
const third = byteAt(index + 2);
|
|
527
|
+
if (third === 10) return 3;
|
|
528
|
+
if (third !== 13) return 0;
|
|
529
|
+
if (index + 3 >= length) return undefined;
|
|
530
|
+
return byteAt(index + 3) === 10 ? 4 : 0;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function joinedBytes(slices: readonly Uint8Array[], byteLength: number): Uint8Array {
|
|
534
|
+
if (slices.length === 1 && slices[0]!.byteLength === byteLength) return slices[0]!;
|
|
535
|
+
const joined = new Uint8Array(byteLength);
|
|
536
|
+
let offset = 0;
|
|
537
|
+
for (const slice of slices) {
|
|
538
|
+
joined.set(slice, offset);
|
|
539
|
+
offset += slice.byteLength;
|
|
540
|
+
}
|
|
541
|
+
return joined;
|
|
542
|
+
}
|
|
543
|
+
|
|
423
544
|
/**
|
|
424
545
|
* Per-chunk SSE inspection state machine shared by consumeForInspection,
|
|
425
546
|
* consumeForResponseLogMetadata, and the eager bounded relay (relay-eager.ts).
|
|
@@ -435,44 +556,136 @@ export type SseInspector = {
|
|
|
435
556
|
* - Synthetic terminals (incomplete / failed-502) are the CALLER's decision:
|
|
436
557
|
* the caller owns `cancelled` state and reads `reported()` to decide.
|
|
437
558
|
*/
|
|
438
|
-
export function createSseInspector(handlers: {
|
|
439
|
-
|
|
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 = "";
|
|
559
|
+
export function createSseInspector(handlers: SseInspectorHandlers): SseInspector {
|
|
560
|
+
let decoder: TextDecoder | null = new TextDecoder();
|
|
446
561
|
let reported = false;
|
|
562
|
+
let sawTerminal = false;
|
|
563
|
+
let disposed = false;
|
|
564
|
+
let delimiterTail = new Uint8Array(0);
|
|
565
|
+
let candidateSlices: Uint8Array[] = [];
|
|
566
|
+
let candidateBytes = 0;
|
|
567
|
+
let discardingOversizedFrame = false;
|
|
447
568
|
const reportFirstOutput = createFirstOutputReporter(handlers.onFirstOutput);
|
|
448
569
|
// Allocate reconstruction state only for persistence-capable inspectors.
|
|
449
570
|
const completedItemsByOutputIndex = handlers.onCompletedResponse
|
|
450
|
-
? new Map<number,
|
|
571
|
+
? new Map<number, CompletedOutputItem>()
|
|
451
572
|
: null;
|
|
573
|
+
let aggregateItemBytes = 0;
|
|
574
|
+
let reconstructionTainted = false;
|
|
575
|
+
|
|
576
|
+
const clearFrameState = (): void => {
|
|
577
|
+
delimiterTail = new Uint8Array(0);
|
|
578
|
+
candidateSlices = [];
|
|
579
|
+
candidateBytes = 0;
|
|
580
|
+
discardingOversizedFrame = false;
|
|
581
|
+
};
|
|
452
582
|
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
583
|
+
const clearCompletedItems = (): void => {
|
|
584
|
+
completedItemsByOutputIndex?.clear();
|
|
585
|
+
aggregateItemBytes = 0;
|
|
586
|
+
reconstructionTainted = false;
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const dispose = (): void => {
|
|
590
|
+
if (disposed) return;
|
|
591
|
+
disposed = true;
|
|
592
|
+
decoder = null;
|
|
593
|
+
clearFrameState();
|
|
594
|
+
clearCompletedItems();
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const retainCandidateSlice = (slice: Uint8Array): void => {
|
|
598
|
+
if (slice.byteLength === 0 || discardingOversizedFrame) return;
|
|
599
|
+
const nextBytes = candidateBytes + slice.byteLength;
|
|
600
|
+
inspectionCounters.frameBufferHighWaterBytes = Math.max(
|
|
601
|
+
inspectionCounters.frameBufferHighWaterBytes,
|
|
602
|
+
Math.min(nextBytes, MAX_INSPECTION_SSE_FRAME_BYTES),
|
|
603
|
+
);
|
|
604
|
+
if (nextBytes > MAX_INSPECTION_SSE_FRAME_BYTES) {
|
|
605
|
+
candidateSlices = [];
|
|
606
|
+
candidateBytes = 0;
|
|
607
|
+
discardingOversizedFrame = true;
|
|
608
|
+
inspectionCounters.frameCapOverflows += 1;
|
|
609
|
+
// The rejected frame may have carried an output item we will never see;
|
|
610
|
+
// any later empty-output terminal must not synthesize a partial replay
|
|
611
|
+
// from the surviving map entries (same taint rule as item eviction).
|
|
612
|
+
reconstructionTainted = true;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
// `subarray()` aliases the upstream chunk's backing buffer. Copy only the
|
|
616
|
+
// live candidate bytes so a tiny trailing frame cannot pin a multi-MiB
|
|
617
|
+
// chunk whose preceding frames have already been consumed.
|
|
618
|
+
candidateSlices.push(slice.slice());
|
|
619
|
+
candidateBytes = nextBytes;
|
|
620
|
+
};
|
|
621
|
+
|
|
622
|
+
const retainCompletedItem = (index: number, item: unknown, sourceBytes: number): void => {
|
|
623
|
+
const previous = completedItemsByOutputIndex!.get(index);
|
|
624
|
+
if (previous) {
|
|
625
|
+
aggregateItemBytes -= previous.sourceBytes;
|
|
626
|
+
completedItemsByOutputIndex!.delete(index);
|
|
627
|
+
}
|
|
628
|
+
if (sourceBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) {
|
|
629
|
+
reconstructionTainted = true;
|
|
630
|
+
inspectionCounters.itemCapEvictions += 1;
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
completedItemsByOutputIndex!.set(index, { item, sourceBytes });
|
|
634
|
+
aggregateItemBytes += sourceBytes;
|
|
635
|
+
while (completedItemsByOutputIndex!.size > MAX_COMPLETED_OUTPUT_ITEMS
|
|
636
|
+
|| aggregateItemBytes > MAX_COMPLETED_OUTPUT_ITEM_SOURCE_BYTES) {
|
|
637
|
+
let highestIndex = -1;
|
|
638
|
+
for (const retainedIndex of completedItemsByOutputIndex!.keys()) {
|
|
639
|
+
if (retainedIndex > highestIndex) highestIndex = retainedIndex;
|
|
640
|
+
}
|
|
641
|
+
const evicted = completedItemsByOutputIndex!.get(highestIndex);
|
|
642
|
+
if (!evicted) break;
|
|
643
|
+
completedItemsByOutputIndex!.delete(highestIndex);
|
|
644
|
+
aggregateItemBytes -= evicted.sourceBytes;
|
|
645
|
+
reconstructionTainted = true;
|
|
646
|
+
inspectionCounters.itemCapEvictions += 1;
|
|
647
|
+
}
|
|
648
|
+
inspectionCounters.completedItemsMaxCount = Math.max(
|
|
649
|
+
inspectionCounters.completedItemsMaxCount,
|
|
650
|
+
completedItemsByOutputIndex!.size,
|
|
651
|
+
);
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const scanPayload = (payload: string | null, sourceBytes: number): void => {
|
|
456
655
|
if (!payload) return;
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
656
|
+
let parsed: unknown | undefined;
|
|
657
|
+
if (payload !== "[DONE]") {
|
|
658
|
+
try {
|
|
659
|
+
parsed = JSON.parse(payload);
|
|
660
|
+
} catch {
|
|
661
|
+
/* malformed SSE payloads remain best-effort/no-throw */
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (!reported && handlers.logCtx) {
|
|
665
|
+
inspectResponseLogSsePayloadParsed(handlers.logCtx, payload, parsed);
|
|
666
|
+
}
|
|
667
|
+
reportFirstOutput.parsed(parsed);
|
|
668
|
+
const status = terminalStatusFromParsed(parsed);
|
|
669
|
+
if (status) sawTerminal = true;
|
|
670
|
+
if (!reported && handlers.onTerminal && status) {
|
|
671
|
+
try {
|
|
460
672
|
reported = true;
|
|
461
673
|
if (handlers.logCtx) {
|
|
462
674
|
handlers.logCtx.transportPhase = "terminal_sse";
|
|
463
675
|
handlers.logCtx.terminalSource = "upstream";
|
|
464
676
|
}
|
|
465
677
|
handlers.onTerminal(status);
|
|
678
|
+
} finally {
|
|
679
|
+
if (status === "failed" || status === "incomplete") clearCompletedItems();
|
|
466
680
|
}
|
|
681
|
+
} else if (status === "failed" || status === "incomplete") {
|
|
682
|
+
clearCompletedItems();
|
|
467
683
|
}
|
|
468
684
|
if (handlers.onCompletedResponse) {
|
|
469
685
|
type ParsedSseEvent = { type?: unknown; output_index?: unknown; item?: unknown; response?: unknown };
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
} catch {
|
|
474
|
-
/* malformed SSE payloads remain best-effort/no-throw */
|
|
475
|
-
}
|
|
686
|
+
const parsedEvent = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
687
|
+
? parsed as ParsedSseEvent
|
|
688
|
+
: null;
|
|
476
689
|
const doneItem = parsedEvent?.type === "response.output_item.done" ? parsedEvent.item : undefined;
|
|
477
690
|
if (parsedEvent
|
|
478
691
|
&& doneItem !== undefined
|
|
@@ -482,107 +695,309 @@ export function createSseInspector(handlers: {
|
|
|
482
695
|
&& doneItem !== null
|
|
483
696
|
&& !Array.isArray(doneItem)
|
|
484
697
|
&& typeof (doneItem as { type?: unknown }).type === "string") {
|
|
485
|
-
|
|
698
|
+
retainCompletedItem(parsedEvent.output_index as number, doneItem, sourceBytes);
|
|
486
699
|
}
|
|
487
700
|
|
|
488
701
|
let response = completedResponseFromParsedEvent(parsedEvent);
|
|
489
|
-
if (response
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
702
|
+
if (response) {
|
|
703
|
+
// Authoritative output is a NON-EMPTY ARRAY only. Anything else
|
|
704
|
+
// (missing, null, scalar, object) keeps the historical backfill
|
|
705
|
+
// behavior so a malformed terminal cannot reach rememberResponseState
|
|
706
|
+
// and destroy continuation state (review C1-2).
|
|
707
|
+
const hasAuthoritativeOutput = Array.isArray(response.output)
|
|
708
|
+
&& response.output.length > 0;
|
|
709
|
+
if (!hasAuthoritativeOutput && reconstructionTainted) {
|
|
710
|
+
clearCompletedItems();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (!hasAuthoritativeOutput && completedItemsByOutputIndex!.size > 0) {
|
|
714
|
+
response = {
|
|
715
|
+
...response,
|
|
716
|
+
output: [...completedItemsByOutputIndex!.entries()]
|
|
717
|
+
.sort(([left], [right]) => left - right)
|
|
718
|
+
.map(([, retained]) => retained.item),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
try {
|
|
722
|
+
handlers.onCompletedResponse(response);
|
|
723
|
+
} finally {
|
|
724
|
+
clearCompletedItems();
|
|
725
|
+
}
|
|
726
|
+
} else if (parsedEvent?.type === "response.completed") {
|
|
727
|
+
clearCompletedItems();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
const completeCandidate = (): void => {
|
|
733
|
+
if (discardingOversizedFrame) {
|
|
734
|
+
discardingOversizedFrame = false;
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
const sourceBytes = candidateBytes;
|
|
738
|
+
const frame = joinedBytes(candidateSlices, sourceBytes);
|
|
739
|
+
candidateSlices = [];
|
|
740
|
+
candidateBytes = 0;
|
|
741
|
+
if (reported && !handlers.onCompletedResponse) return;
|
|
742
|
+
const decoded = decoder!.decode(frame);
|
|
743
|
+
scanPayload(sseDataPayload(decoded), sourceBytes);
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const scanChunk = (chunk: Uint8Array): void => {
|
|
747
|
+
const previousTail = delimiterTail;
|
|
748
|
+
delimiterTail = new Uint8Array(0);
|
|
749
|
+
const tailLength = previousTail.byteLength;
|
|
750
|
+
const totalLength = tailLength + chunk.byteLength;
|
|
751
|
+
const byteAt = (index: number): number => index < tailLength
|
|
752
|
+
? previousTail[index]!
|
|
753
|
+
: chunk[index - tailLength]!;
|
|
754
|
+
const retainRange = (start: number, end: number): void => {
|
|
755
|
+
if (end <= start || discardingOversizedFrame) return;
|
|
756
|
+
if (start < tailLength) {
|
|
757
|
+
retainCandidateSlice(previousTail.subarray(start, Math.min(end, tailLength)));
|
|
758
|
+
}
|
|
759
|
+
if (end > tailLength) {
|
|
760
|
+
retainCandidateSlice(chunk.subarray(Math.max(0, start - tailLength), end - tailLength));
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
let index = 0;
|
|
764
|
+
let retainedThrough = 0;
|
|
765
|
+
while (index < totalLength) {
|
|
766
|
+
const delimiterLength = delimiterLengthAt(index, totalLength, byteAt);
|
|
767
|
+
if (delimiterLength === undefined) break;
|
|
768
|
+
if (delimiterLength > 0) {
|
|
769
|
+
retainRange(retainedThrough, index);
|
|
770
|
+
completeCandidate();
|
|
771
|
+
index += delimiterLength;
|
|
772
|
+
retainedThrough = index;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
index += 1;
|
|
776
|
+
}
|
|
777
|
+
retainRange(retainedThrough, index);
|
|
778
|
+
if (index < totalLength) {
|
|
779
|
+
delimiterTail = new Uint8Array(totalLength - index);
|
|
780
|
+
for (let offset = 0; offset < delimiterTail.byteLength; offset += 1) {
|
|
781
|
+
delimiterTail[offset] = byteAt(index + offset);
|
|
498
782
|
}
|
|
499
|
-
if (response) handlers.onCompletedResponse(response);
|
|
500
783
|
}
|
|
501
784
|
};
|
|
502
785
|
|
|
503
786
|
return {
|
|
504
787
|
feed(chunk) {
|
|
505
|
-
|
|
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
|
-
}
|
|
788
|
+
if (!disposed) scanChunk(chunk);
|
|
512
789
|
},
|
|
513
790
|
finish() {
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
791
|
+
if (disposed) return;
|
|
792
|
+
try {
|
|
793
|
+
retainCandidateSlice(delimiterTail);
|
|
794
|
+
delimiterTail = new Uint8Array(0);
|
|
795
|
+
if (!discardingOversizedFrame && candidateBytes > 0 && !reported) {
|
|
796
|
+
const sourceBytes = candidateBytes;
|
|
797
|
+
const decoded = decoder!.decode(joinedBytes(candidateSlices, sourceBytes));
|
|
798
|
+
scanPayload(decoded.trim() ? sseDataPayload(decoded) : null, sourceBytes);
|
|
799
|
+
}
|
|
800
|
+
} finally {
|
|
801
|
+
clearFrameState();
|
|
802
|
+
clearCompletedItems();
|
|
517
803
|
}
|
|
518
|
-
buffer = "";
|
|
519
804
|
},
|
|
805
|
+
dispose,
|
|
520
806
|
reported: () => reported,
|
|
807
|
+
terminalSeen: () => sawTerminal,
|
|
521
808
|
};
|
|
522
809
|
}
|
|
523
810
|
|
|
524
|
-
export
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
811
|
+
export type InspectionDrainBounds = { ms: number; bytes: number };
|
|
812
|
+
|
|
813
|
+
export type InspectionConsumerOptions = {
|
|
814
|
+
clientGoneSignal?: AbortSignal;
|
|
815
|
+
drainBounds?: Partial<InspectionDrainBounds>;
|
|
816
|
+
upstream?: AbortController;
|
|
817
|
+
now?: () => number;
|
|
818
|
+
/** Test seam for proving both public consumers dispose their owned inspector. */
|
|
819
|
+
inspectorFactory?: (handlers: SseInspectorHandlers) => SseInspector;
|
|
820
|
+
};
|
|
821
|
+
|
|
822
|
+
const DEFAULT_INSPECTION_DRAIN_MS = 15_000;
|
|
823
|
+
const DEFAULT_INSPECTION_DRAIN_BYTES = 32 * 1024 * 1024;
|
|
824
|
+
type InspectionPumpOptions = InspectionConsumerOptions & {
|
|
825
|
+
reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
826
|
+
inspector: SseInspector;
|
|
827
|
+
signal?: AbortSignal;
|
|
828
|
+
onDone?: () => void;
|
|
829
|
+
onCancel?: () => void;
|
|
830
|
+
onCleanEof?: () => void;
|
|
831
|
+
onReadError?: () => void;
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
function startBoundedInspectionPump(options: InspectionPumpOptions): void {
|
|
835
|
+
const { reader, inspector, signal, clientGoneSignal } = options;
|
|
536
836
|
let cancelled = false;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
837
|
+
let clientGone = false;
|
|
838
|
+
let clientGoneReason: unknown;
|
|
839
|
+
let drainedBytes = 0;
|
|
840
|
+
let drainDeadline = Number.POSITIVE_INFINITY;
|
|
841
|
+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
|
|
842
|
+
let drainStopped = false;
|
|
843
|
+
const drainMs = options.drainBounds?.ms ?? DEFAULT_INSPECTION_DRAIN_MS;
|
|
844
|
+
const drainBytes = options.drainBounds?.bytes ?? DEFAULT_INSPECTION_DRAIN_BYTES;
|
|
845
|
+
const now = options.now ?? Date.now;
|
|
846
|
+
let cancelFired = false;
|
|
847
|
+
const fireCancel = () => {
|
|
848
|
+
if (cancelFired) return;
|
|
849
|
+
cancelFired = true;
|
|
850
|
+
options.onCancel?.();
|
|
851
|
+
};
|
|
852
|
+
const markClientGone = () => {
|
|
853
|
+
if (clientGone || cancelled) return;
|
|
854
|
+
clientGone = true;
|
|
855
|
+
clientGoneReason = clientGoneSignal?.reason;
|
|
856
|
+
drainDeadline = now() + drainMs;
|
|
857
|
+
if (inspector.terminalSeen() || drainMs <= 0 || drainBytes <= 0) {
|
|
858
|
+
stopDrain();
|
|
546
859
|
return;
|
|
547
860
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
861
|
+
// Do not unref: on Bun/Windows a pending `reader.read()` can be the only
|
|
862
|
+
// wake source; an unref'd timer may never run, so a silent post-cancel
|
|
863
|
+
// drain (time bound, no bytes) hangs the suite until the job timeout.
|
|
864
|
+
drainTimer = setTimeout(stopDrain, drainMs);
|
|
865
|
+
};
|
|
866
|
+
// Ends the bounded drain by cancelling the reader: the pending read settles
|
|
867
|
+
// and the pump loop observes `drainStopped`. Deliberately NOT a shared
|
|
868
|
+
// Promise.race companion — racing every read against one pending promise
|
|
869
|
+
// retains O(chunk-count) reactions on long streams (review C1-1), the exact
|
|
870
|
+
// retention class this phase removes.
|
|
871
|
+
const stopDrain = () => {
|
|
872
|
+
if (drainStopped || cancelled) return;
|
|
873
|
+
drainStopped = true;
|
|
874
|
+
reader.cancel(clientGoneReason).catch(() => {});
|
|
875
|
+
};
|
|
876
|
+
const abortImmediately = () => {
|
|
877
|
+
if (cancelled) return;
|
|
878
|
+
cancelled = true;
|
|
879
|
+
reader.cancel(signal?.reason).catch(() => {});
|
|
880
|
+
fireCancel();
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
if (signal?.aborted) {
|
|
884
|
+
cancelled = true;
|
|
885
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
886
|
+
inspector.dispose();
|
|
887
|
+
fireCancel();
|
|
888
|
+
options.onDone?.();
|
|
889
|
+
return;
|
|
555
890
|
}
|
|
891
|
+
signal?.addEventListener("abort", abortImmediately, { once: true });
|
|
892
|
+
clientGoneSignal?.addEventListener("abort", markClientGone, { once: true });
|
|
893
|
+
if (clientGoneSignal?.aborted) markClientGone();
|
|
894
|
+
|
|
556
895
|
const pump = async () => {
|
|
896
|
+
let clientGoneWithoutTerminal = false;
|
|
897
|
+
let boundEndedDrain = false;
|
|
557
898
|
try {
|
|
558
899
|
for (;;) {
|
|
559
900
|
const { done, value } = await reader.read();
|
|
901
|
+
if (drainStopped) {
|
|
902
|
+
// stopDrain() cancelled the reader; the settled read is the wake-up.
|
|
903
|
+
clientGoneWithoutTerminal = !inspector.terminalSeen();
|
|
904
|
+
boundEndedDrain = clientGoneWithoutTerminal;
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
560
907
|
if (done) {
|
|
561
908
|
inspector.finish();
|
|
562
|
-
if (!inspector.
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
909
|
+
if (clientGone) clientGoneWithoutTerminal = !inspector.terminalSeen();
|
|
910
|
+
else if (!cancelled) options.onCleanEof?.();
|
|
911
|
+
break;
|
|
912
|
+
}
|
|
913
|
+
if (!clientGone) {
|
|
914
|
+
inspector.feed(value);
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (now() >= drainDeadline) {
|
|
918
|
+
clientGoneWithoutTerminal = true;
|
|
919
|
+
boundEndedDrain = true;
|
|
920
|
+
break;
|
|
921
|
+
}
|
|
922
|
+
const remainingBytes = Math.max(0, drainBytes - drainedBytes);
|
|
923
|
+
const inspectedValue = value.byteLength > remainingBytes
|
|
924
|
+
? value.subarray(0, remainingBytes)
|
|
925
|
+
: value;
|
|
926
|
+
if (inspectedValue.byteLength > 0) inspector.feed(inspectedValue);
|
|
927
|
+
drainedBytes += inspectedValue.byteLength;
|
|
928
|
+
if (inspector.terminalSeen()) break;
|
|
929
|
+
if (value.byteLength > remainingBytes
|
|
930
|
+
|| drainedBytes >= drainBytes
|
|
931
|
+
|| now() >= drainDeadline) {
|
|
932
|
+
clientGoneWithoutTerminal = true;
|
|
933
|
+
boundEndedDrain = true;
|
|
934
|
+
break;
|
|
567
935
|
}
|
|
568
|
-
inspector.feed(value);
|
|
569
936
|
}
|
|
570
937
|
} catch {
|
|
938
|
+
if (clientGone) clientGoneWithoutTerminal = !inspector.terminalSeen();
|
|
939
|
+
else if (!cancelled) options.onReadError?.();
|
|
940
|
+
} finally {
|
|
941
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
942
|
+
signal?.removeEventListener("abort", abortImmediately);
|
|
943
|
+
clientGoneSignal?.removeEventListener("abort", markClientGone);
|
|
944
|
+
if (clientGone) {
|
|
945
|
+
if (boundEndedDrain) inspectionCounters.postCancelDrainStops += 1;
|
|
946
|
+
if (clientGoneWithoutTerminal) fireCancel();
|
|
947
|
+
options.upstream?.abort(clientGoneReason);
|
|
948
|
+
reader.cancel(clientGoneReason).catch(() => {});
|
|
949
|
+
}
|
|
950
|
+
inspector.dispose();
|
|
951
|
+
options.onDone?.();
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
void pump();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
export function consumeForInspection(
|
|
958
|
+
body: ReadableStream<Uint8Array>,
|
|
959
|
+
onTerminal: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void,
|
|
960
|
+
signal?: AbortSignal,
|
|
961
|
+
onDone?: () => void,
|
|
962
|
+
logCtx?: RequestLogContext,
|
|
963
|
+
onCancel?: () => void,
|
|
964
|
+
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
|
|
965
|
+
onFirstOutput?: () => void,
|
|
966
|
+
options?: InspectionConsumerOptions,
|
|
967
|
+
): void {
|
|
968
|
+
const reader = body.getReader();
|
|
969
|
+
const inspector = (options?.inspectorFactory ?? createSseInspector)({
|
|
970
|
+
onTerminal,
|
|
971
|
+
logCtx,
|
|
972
|
+
onCompletedResponse,
|
|
973
|
+
onFirstOutput,
|
|
974
|
+
});
|
|
975
|
+
startBoundedInspectionPump({
|
|
976
|
+
...options,
|
|
977
|
+
reader,
|
|
978
|
+
inspector,
|
|
979
|
+
signal,
|
|
980
|
+
onDone,
|
|
981
|
+
onCancel,
|
|
982
|
+
onCleanEof: () => {
|
|
983
|
+
if (!inspector.reported()) {
|
|
984
|
+
if (logCtx) logCtx.terminalSource = "synthetic";
|
|
985
|
+
onTerminal("incomplete");
|
|
986
|
+
}
|
|
987
|
+
},
|
|
988
|
+
onReadError: () => {
|
|
571
989
|
// Upstream read failure after HTTP 200 (mid-stream socket reset) is not a
|
|
572
990
|
// protocol `response.incomplete` terminal. Report a synthetic 502 so account
|
|
573
991
|
// health treats it as transient; abort-driven client cancellation still wins.
|
|
574
|
-
if (!inspector.reported()
|
|
992
|
+
if (!inspector.reported()) {
|
|
575
993
|
if (logCtx) {
|
|
576
994
|
logCtx.transportPhase = "mid_stream";
|
|
577
995
|
logCtx.terminalSource = "synthetic";
|
|
578
996
|
}
|
|
579
997
|
onTerminal("failed", 502);
|
|
580
998
|
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
}
|
|
584
|
-
};
|
|
585
|
-
pump();
|
|
999
|
+
},
|
|
1000
|
+
});
|
|
586
1001
|
}
|
|
587
1002
|
|
|
588
1003
|
export function consumeForResponseLogMetadata(
|
|
@@ -592,38 +1007,17 @@ export function consumeForResponseLogMetadata(
|
|
|
592
1007
|
onDone?: () => void,
|
|
593
1008
|
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void,
|
|
594
1009
|
onFirstOutput?: () => void,
|
|
1010
|
+
options?: InspectionConsumerOptions,
|
|
595
1011
|
): void {
|
|
596
1012
|
const reader = body.getReader();
|
|
597
1013
|
// No onTerminal → the inspector's `reported` gate stays permanently false,
|
|
598
1014
|
// reproducing this consumer's unconditional logCtx inspection.
|
|
599
|
-
const inspector =
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
}
|
|
606
|
-
signal.addEventListener("abort", () => {
|
|
607
|
-
reader.cancel(signal.reason).catch(() => {});
|
|
608
|
-
}, { once: true });
|
|
609
|
-
}
|
|
610
|
-
const pump = async () => {
|
|
611
|
-
try {
|
|
612
|
-
for (;;) {
|
|
613
|
-
const { done, value } = await reader.read();
|
|
614
|
-
if (done) {
|
|
615
|
-
inspector.finish();
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
inspector.feed(value);
|
|
619
|
-
}
|
|
620
|
-
} catch {
|
|
621
|
-
/* metadata inspection must not affect the client-facing stream */
|
|
622
|
-
} finally {
|
|
623
|
-
onDone?.();
|
|
624
|
-
}
|
|
625
|
-
};
|
|
626
|
-
pump();
|
|
1015
|
+
const inspector = (options?.inspectorFactory ?? createSseInspector)({
|
|
1016
|
+
logCtx,
|
|
1017
|
+
onCompletedResponse,
|
|
1018
|
+
onFirstOutput,
|
|
1019
|
+
});
|
|
1020
|
+
startBoundedInspectionPump({ ...options, reader, inspector, signal, onDone });
|
|
627
1021
|
}
|
|
628
1022
|
|
|
629
1023
|
/**
|