@bitkyc08/opencodex 2.11.1 → 2.12.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 +1 -1
- package/bin/ocx.mjs +4 -0
- package/gui/dist/assets/index-BNVYzdn0.css +1 -0
- package/gui/dist/assets/index-Cw1_mxO8.js +70 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/command-code.ts +94 -5
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-tool-schema.ts +80 -15
- package/src/adapters/google.ts +24 -7
- package/src/adapters/openai-chat.ts +233 -305
- package/src/adapters/openai-responses.ts +62 -0
- package/src/adapters/tool-catalog-nudge.ts +2 -11
- package/src/claude/outbound.ts +42 -14
- package/src/cli/claude.ts +61 -9
- package/src/cli/codex-shim-autorestore.ts +2 -0
- package/src/cli/codex-shim-readiness.ts +8 -1
- package/src/cli/config-command.ts +4 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/help.ts +15 -0
- package/src/cli/index.ts +27 -11
- package/src/cli/lab.ts +367 -0
- package/src/cli/provider.ts +9 -1
- package/src/clients/config-export.ts +100 -3
- package/src/codex/app-server-processes.ts +11 -8
- package/src/codex/auth-api.ts +92 -28
- package/src/codex/auth-collision.ts +5 -3
- package/src/codex/catalog/metadata.ts +7 -3
- package/src/codex/catalog/provider-fetch.ts +191 -47
- package/src/codex/catalog.ts +2 -2
- package/src/codex/history-job.ts +83 -18
- package/src/codex/history-migration-guardian.ts +9 -16
- package/src/codex/history-provider.ts +178 -1
- package/src/codex/history-transition.ts +5 -5
- package/src/codex/history-worker.ts +22 -2
- package/src/codex/native-profile-store.ts +1 -1
- package/src/codex/plan.ts +15 -0
- package/src/codex/quota.ts +6 -6
- package/src/codex/routing.ts +3 -3
- package/src/codex/subagent-model-fallback.ts +246 -22
- package/src/codex/user-identity.ts +63 -12
- package/src/config.ts +265 -32
- package/src/images/loop.ts +3 -1
- package/src/images/plan.ts +23 -13
- package/src/integrations/omp-yaml-source.ts +225 -0
- package/src/integrations/ownership.ts +5 -5
- package/src/integrations/state.ts +45 -6
- package/src/integrations/writer.ts +38 -3
- package/src/lab/artifacts/sanitize.ts +586 -0
- package/src/lab/artifacts/secure-fs.ts +475 -0
- package/src/lab/artifacts/store.ts +310 -0
- package/src/lab/conformance/assertion.ts +325 -0
- package/src/lab/conformance/digest.ts +22 -0
- package/src/lab/conformance/executor.ts +741 -0
- package/src/lab/conformance/fixture-provider.ts +27 -0
- package/src/lab/conformance/fixtures/live-v1-cases.json +175 -0
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +461 -0
- package/src/lab/conformance/harness-budget.ts +47 -0
- package/src/lab/conformance/index.ts +5 -0
- package/src/lab/conformance/jcs.ts +24 -0
- package/src/lab/conformance/json-pointer.ts +39 -0
- package/src/lab/conformance/manifest.ts +180 -0
- package/src/lab/conformance/mcp-stub.ts +179 -0
- package/src/lab/conformance/negative-controls.ts +160 -0
- package/src/lab/conformance/observation.ts +355 -0
- package/src/lab/conformance/runner.ts +57 -0
- package/src/lab/conformance/sse-normalize.ts +59 -0
- package/src/lab/conformance/suite-manifest.ts +78 -0
- package/src/lab/conformance/types.ts +191 -0
- package/src/lab/constants.ts +126 -0
- package/src/lab/digest.ts +64 -0
- package/src/lab/events/errors.ts +9 -0
- package/src/lab/events/limits.ts +117 -0
- package/src/lab/events/types.ts +228 -0
- package/src/lab/events/validate.ts +780 -0
- package/src/lab/index.ts +36 -0
- package/src/lab/ledger/artifact-refs.ts +127 -0
- package/src/lab/ledger/invalidation.ts +136 -0
- package/src/lab/ledger/purge.ts +241 -0
- package/src/lab/ledger/store.ts +263 -0
- package/src/lab/live/credential-lease.ts +53 -0
- package/src/lab/live/destination.ts +155 -0
- package/src/lab/live/executor.ts +311 -0
- package/src/lab/live/inert-tools.ts +56 -0
- package/src/lab/live/manifest.ts +85 -0
- package/src/lab/live/mcp-loopback.ts +57 -0
- package/src/lab/live/runner.ts +19 -0
- package/src/lab/live/sandbox.ts +61 -0
- package/src/lab/live/suite-manifest.ts +41 -0
- package/src/lab/live/transport.ts +116 -0
- package/src/lab/live/types.ts +197 -0
- package/src/lab/observe/from-conformance.ts +344 -0
- package/src/lab/observe/from-live.ts +103 -0
- package/src/lab/paths.ts +65 -0
- package/src/lab/projection/rebuild.ts +477 -0
- package/src/lab/projection/schema.ts +122 -0
- package/src/lab/projection/verdicts.ts +438 -0
- package/src/lab/projection/verification.ts +332 -0
- package/src/lab/query/catalog.ts +72 -0
- package/src/lab/query/connection.ts +107 -0
- package/src/lab/query/constants.ts +4 -0
- package/src/lab/query/cursor.ts +132 -0
- package/src/lab/query/dto-map.ts +277 -0
- package/src/lab/query/errors.ts +22 -0
- package/src/lab/query/index.ts +34 -0
- package/src/lab/query/queries.ts +444 -0
- package/src/lab/query/types.ts +266 -0
- package/src/lab/subject/behavior-fingerprint.ts +77 -0
- package/src/lab/subject/installation-salt.ts +81 -0
- package/src/lab/subject/route-subject.ts +73 -0
- package/src/lib/bounded-body.ts +104 -0
- package/src/lib/lab-live-execution-authority.ts +13 -0
- package/src/lib/lab-live-host.ts +30 -0
- package/src/lib/lab-live-pinned-sender.ts +45 -0
- package/src/lib/pinned-http.ts +36 -7
- package/src/oauth/index.ts +20 -1
- package/src/oauth/local-token-detect.ts +11 -2
- package/src/oauth/login-cli.ts +38 -4
- package/src/providers/alibaba-region-migration.ts +1 -1
- package/src/providers/codex-capacity.ts +7 -4
- package/src/providers/command-code-efforts.ts +5 -2
- package/src/providers/derive.ts +105 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/openai-tiers.ts +59 -3
- package/src/providers/opencode-zen-rate-limit.ts +102 -0
- package/src/providers/quota.ts +67 -21
- package/src/providers/registry.ts +118 -5
- package/src/router.ts +15 -1
- package/src/routing/analytics.ts +8 -7
- package/src/routing/evaluator.ts +51 -5
- package/src/routing/profile.ts +10 -1
- package/src/routing/trace.ts +42 -6
- package/src/server/auth-cors.ts +15 -0
- package/src/server/background-lifecycle.ts +169 -0
- package/src/server/chat-completions.ts +18 -3
- package/src/server/claude-messages.ts +1 -0
- package/src/server/images.ts +78 -11
- package/src/server/index.ts +72 -18
- package/src/server/lifecycle.ts +6 -0
- package/src/server/live.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +41 -24
- package/src/server/management/body.ts +6 -0
- package/src/server/management/config-routes.ts +51 -8
- package/src/server/management/lab-routes.ts +388 -0
- package/src/server/management/logs-usage-routes.ts +19 -1
- package/src/server/management/model-rows.ts +25 -11
- package/src/server/management/native-integration-routes.ts +3 -3
- package/src/server/management/provider-routes.ts +5 -0
- package/src/server/management/shared.ts +8 -1
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management/vision-sidecar-options.ts +125 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/request-decompress.ts +106 -5
- package/src/server/responses/collaboration.ts +4 -1
- package/src/server/responses/core.ts +117 -31
- package/src/server/responses-json-events.ts +50 -12
- package/src/server/search.ts +23 -7
- package/src/server/ws-bridge.ts +3 -1
- package/src/service.ts +40 -20
- package/src/types.ts +74 -0
- package/src/update/job.ts +10 -2
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +9 -0
- package/src/usage/log.ts +9 -9
- package/src/usage/user-cost-overlay-reconciler.ts +313 -0
- package/src/usage/user-cost-overlays.ts +314 -0
- package/src/vision/describe.ts +2 -2
- package/src/vision/eligibility.ts +229 -0
- package/src/vision/index.ts +20 -2
- package/src/web-search/executor.ts +2 -2
- package/src/web-search/index.ts +4 -2
- package/src/web-search/loop.ts +3 -1
- package/src/web-search/parse.ts +72 -3
- package/gui/dist/assets/index-BA1dgo4q.css +0 -1
- package/gui/dist/assets/index-DDZpgzKk.js +0 -70
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place that decides which models the management API offers as vision
|
|
3
|
+
* describers, and which it refuses to persist.
|
|
4
|
+
*
|
|
5
|
+
* Two routes write a vision sidecar model — `PUT /api/sidecar-settings` and the
|
|
6
|
+
* `visionSidecar` override in `PUT /api/claude-code`. They share this module so
|
|
7
|
+
* the policy cannot drift: a gate on one route and a stale copy on the other is
|
|
8
|
+
* the same as no gate at all.
|
|
9
|
+
*/
|
|
10
|
+
import type { OcxConfig } from "../../types";
|
|
11
|
+
import { findAnthropicVisionProvider, type AnthropicVisionProvider } from "../../vision";
|
|
12
|
+
import {
|
|
13
|
+
modelAcceptsImageInput,
|
|
14
|
+
visionEligibleModelOptions,
|
|
15
|
+
type VisionCandidateModel,
|
|
16
|
+
type VisionModelOption,
|
|
17
|
+
type VisionSidecarBackend,
|
|
18
|
+
} from "../../vision/eligibility";
|
|
19
|
+
import { listOpenAiForwardSidecarCandidates } from "../../providers/openai-sidecar";
|
|
20
|
+
import { listManagementModelRows } from "./model-rows";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Backends whose executor could actually run: openai forward, anthropic OAuth.
|
|
24
|
+
*
|
|
25
|
+
* `anthropicSidecar` is REQUIRED rather than defaulted. `findAnthropicVisionProvider`
|
|
26
|
+
* reads the OAuth account store from disk, and a default argument made every helper
|
|
27
|
+
* in this chain re-resolve it whenever a caller passed an explicit `undefined` —
|
|
28
|
+
* which is exactly the no-executor case. Passing it in keeps one read per request.
|
|
29
|
+
*/
|
|
30
|
+
export function enabledVisionBackends(
|
|
31
|
+
config: OcxConfig,
|
|
32
|
+
anthropicSidecar: AnthropicVisionProvider | undefined,
|
|
33
|
+
): VisionSidecarBackend[] {
|
|
34
|
+
const backends: VisionSidecarBackend[] = [];
|
|
35
|
+
// The OpenAI describer needs a CANONICAL ChatGPT forward provider, not merely a
|
|
36
|
+
// provider keyed "openai" — same predicate the runtime sidecar resolver uses.
|
|
37
|
+
if (listOpenAiForwardSidecarCandidates(config).length > 0) backends.push("openai");
|
|
38
|
+
if (anthropicSidecar) backends.push("anthropic");
|
|
39
|
+
// Neither side resolvable (fresh install, no login): fall back to both so the
|
|
40
|
+
// picker is populated rather than empty, matching the permissive-unknown rule.
|
|
41
|
+
return backends.length > 0 ? backends : ["openai", "anthropic"];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Visible catalog rows in the shape the eligibility predicate consumes. */
|
|
45
|
+
export async function visionCandidateRows(config: OcxConfig): Promise<VisionCandidateModel[]> {
|
|
46
|
+
let rows: Awaited<ReturnType<typeof listManagementModelRows>> = [];
|
|
47
|
+
// A catalog outage must not 500 the settings route nor reject a write; with []
|
|
48
|
+
// the option list degrades to the baselines, which is the intended floor.
|
|
49
|
+
try { rows = await listManagementModelRows(config); } catch { rows = []; }
|
|
50
|
+
return rows.filter(row => row.disabled !== true).map(row => ({
|
|
51
|
+
provider: row.provider,
|
|
52
|
+
id: row.id,
|
|
53
|
+
...(row.inputModalities ? { inputModalities: row.inputModalities } : {}),
|
|
54
|
+
...(row.native ? { native: true } : {}),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function visionModelOptionsFrom(
|
|
59
|
+
config: OcxConfig,
|
|
60
|
+
candidates: readonly VisionCandidateModel[],
|
|
61
|
+
anthropicSidecar: AnthropicVisionProvider | undefined,
|
|
62
|
+
): VisionModelOption[] {
|
|
63
|
+
return visionEligibleModelOptions(
|
|
64
|
+
config,
|
|
65
|
+
candidates,
|
|
66
|
+
enabledVisionBackends(config, anthropicSidecar),
|
|
67
|
+
anthropicSidecar?.providerName,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Convenience for read paths that have no candidate list in hand yet. */
|
|
72
|
+
export async function visionModelOptionsFor(
|
|
73
|
+
config: OcxConfig,
|
|
74
|
+
anthropicSidecar: AnthropicVisionProvider | undefined,
|
|
75
|
+
): Promise<VisionModelOption[]> {
|
|
76
|
+
return visionModelOptionsFrom(config, await visionCandidateRows(config), anthropicSidecar);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Can we PROVE the requested describer is blind?
|
|
81
|
+
*
|
|
82
|
+
* Only a positive `false` rejects. An id no source knows stays allowed, because
|
|
83
|
+
* the runtime never required catalog membership and an operator may be ahead of
|
|
84
|
+
* our tables.
|
|
85
|
+
*
|
|
86
|
+
* When no catalog row matches, the caller's `backend` is only a HINT, never the
|
|
87
|
+
* authority. Trusting it let a client launder a known-blind OpenAI model past the
|
|
88
|
+
* gate by claiming `backend: "anthropic"`, since the id is absent from the
|
|
89
|
+
* Anthropic table and absence reads as "unknown". Both families are therefore
|
|
90
|
+
* consulted and any positive text-only verdict wins. That is safe precisely
|
|
91
|
+
* because the two vendor tables share no bare model id, so they can never
|
|
92
|
+
* disagree about one.
|
|
93
|
+
*/
|
|
94
|
+
export function visionDescriberIsProvablyBlind(
|
|
95
|
+
config: OcxConfig,
|
|
96
|
+
requested: string,
|
|
97
|
+
candidates: readonly VisionCandidateModel[],
|
|
98
|
+
backendHint: VisionSidecarBackend | undefined,
|
|
99
|
+
): boolean {
|
|
100
|
+
// Catalog rows can carry operator-authored modalities. A positive claim from one
|
|
101
|
+
// must not hide another row or canonical metadata that proves this id is blind.
|
|
102
|
+
if (candidates.some(candidate => candidate.id === requested
|
|
103
|
+
&& modelAcceptsImageInput(config, candidate) === false)) return true;
|
|
104
|
+
|
|
105
|
+
const hinted: VisionSidecarBackend = backendHint === "anthropic" ? "anthropic" : "openai";
|
|
106
|
+
const probed: VisionSidecarBackend[] = hinted === "anthropic"
|
|
107
|
+
? ["anthropic", "openai"]
|
|
108
|
+
: ["openai", "anthropic"];
|
|
109
|
+
return probed.some(provider => modelAcceptsImageInput(config, { provider, id: requested }) === false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The 400 body both routes return, so the two errors cannot diverge either. */
|
|
113
|
+
export function visionDescriberRejection(
|
|
114
|
+
field: "vision.model" | "visionSidecar.model",
|
|
115
|
+
requested: string,
|
|
116
|
+
config: OcxConfig,
|
|
117
|
+
candidates: readonly VisionCandidateModel[],
|
|
118
|
+
): { error: string; allowed: string[] } {
|
|
119
|
+
return {
|
|
120
|
+
error: `${field} "${requested}" cannot describe images: it has no image input support, or it is a model the vision sidecar describes FOR.`,
|
|
121
|
+
// The rejection path is not hot, so it resolves the executor itself rather than
|
|
122
|
+
// making every 400 caller thread one through.
|
|
123
|
+
allowed: visionModelOptionsFrom(config, candidates, findAnthropicVisionProvider(config)).map(option => option.value),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -68,6 +68,7 @@ import { handleAgentSettingsRoutes } from "./management/agent-settings-routes";
|
|
|
68
68
|
import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
|
|
69
69
|
import { handleComboRoutes } from "./management/combo-routes";
|
|
70
70
|
import { handleSystemRoutes } from "./management/system-routes";
|
|
71
|
+
import { handleLabRoutes } from "./management/lab-routes";
|
|
71
72
|
import { handleSidebarRoutes } from "./management/sidebar-routes";
|
|
72
73
|
import { handleIntegrationRoutes } from "./management/integration-routes";
|
|
73
74
|
import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
|
|
@@ -187,6 +188,7 @@ export async function handleManagementAPI(
|
|
|
187
188
|
?? (await handleOauthAccountRoutes(ctx))
|
|
188
189
|
?? (await handleComboRoutes(ctx))
|
|
189
190
|
?? (await handleSystemRoutes(ctx))
|
|
191
|
+
?? (await handleLabRoutes(ctx))
|
|
190
192
|
?? (await handleSidebarRoutes(ctx));
|
|
191
193
|
} catch (error) {
|
|
192
194
|
const tooLarge = managementBodyTooLargeResponse(error, req, config);
|
|
@@ -45,6 +45,101 @@ function declaredBodyLength(req: Request): number | null {
|
|
|
45
45
|
return Number.isFinite(length) && length >= 0 ? length : null;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
function cancelStreamWithoutWaiting(stream: ReadableStream<Uint8Array> | null, reason: unknown): void {
|
|
49
|
+
if (!stream || stream.locked) return;
|
|
50
|
+
try {
|
|
51
|
+
void stream.cancel(reason).catch(() => undefined);
|
|
52
|
+
} catch {
|
|
53
|
+
// A non-standard stream may throw synchronously from cancel().
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function cancelReaderWithoutWaiting(reader: ReadableStreamDefaultReader<Uint8Array>, reason: unknown): void {
|
|
58
|
+
// Request.clone() tees can leave cancel() pending until the other branch
|
|
59
|
+
// drains. Cancellation must never extend this reader's own admission bound.
|
|
60
|
+
try {
|
|
61
|
+
void reader.cancel(reason).catch(() => undefined);
|
|
62
|
+
} catch {
|
|
63
|
+
// A non-standard reader may throw synchronously from cancel().
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readRequestBodyBytesCapped(
|
|
68
|
+
body: ReadableStream<Uint8Array> | null,
|
|
69
|
+
maxBytes: number,
|
|
70
|
+
signal?: AbortSignal,
|
|
71
|
+
): Promise<Uint8Array> {
|
|
72
|
+
if (signal?.aborted) {
|
|
73
|
+
cancelStreamWithoutWaiting(body, signal.reason);
|
|
74
|
+
throw signal.reason;
|
|
75
|
+
}
|
|
76
|
+
if (!body) return new Uint8Array(0);
|
|
77
|
+
|
|
78
|
+
const reader = body.getReader();
|
|
79
|
+
// Keep one geometric buffer instead of one object per transport chunk. A
|
|
80
|
+
// hostile peer can fragment a bounded payload into arbitrarily many chunks.
|
|
81
|
+
let retained = new Uint8Array(Math.min(maxBytes, 64 * 1024));
|
|
82
|
+
let retainedBytes = 0;
|
|
83
|
+
let aborted = false;
|
|
84
|
+
let abortReason: unknown;
|
|
85
|
+
let cancellationStarted = false;
|
|
86
|
+
const cancel = (reason: unknown): void => {
|
|
87
|
+
if (cancellationStarted) return;
|
|
88
|
+
cancellationStarted = true;
|
|
89
|
+
cancelReaderWithoutWaiting(reader, reason);
|
|
90
|
+
};
|
|
91
|
+
const onAbort = (): void => {
|
|
92
|
+
aborted = true;
|
|
93
|
+
abortReason = signal?.reason;
|
|
94
|
+
cancel(abortReason);
|
|
95
|
+
};
|
|
96
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
// Close the narrow race between the preflight check and listener install.
|
|
98
|
+
if (signal?.aborted) onAbort();
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
while (true) {
|
|
102
|
+
if (aborted) throw abortReason;
|
|
103
|
+
const { value, done } = await reader.read();
|
|
104
|
+
// cancel() can resolve a pending read as EOF. Preserve the caller's
|
|
105
|
+
// original abort reason instead of misclassifying that as a clean body.
|
|
106
|
+
if (aborted) throw abortReason;
|
|
107
|
+
if (done) {
|
|
108
|
+
return retainedBytes === retained.byteLength
|
|
109
|
+
? retained
|
|
110
|
+
: retained.slice(0, retainedBytes);
|
|
111
|
+
}
|
|
112
|
+
if (!value || value.byteLength === 0) continue;
|
|
113
|
+
|
|
114
|
+
if (value.byteLength > maxBytes - retainedBytes) {
|
|
115
|
+
const error = new DecompressedBodyTooLargeError(retainedBytes + value.byteLength, maxBytes);
|
|
116
|
+
cancel(error);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const required = retainedBytes + value.byteLength;
|
|
121
|
+
if (required > retained.byteLength) {
|
|
122
|
+
const grown = new Uint8Array(Math.min(maxBytes, Math.max(retained.byteLength * 2, required)));
|
|
123
|
+
grown.set(retained.subarray(0, retainedBytes));
|
|
124
|
+
retained = grown;
|
|
125
|
+
}
|
|
126
|
+
retained.set(value, retainedBytes);
|
|
127
|
+
retainedBytes = required;
|
|
128
|
+
}
|
|
129
|
+
} catch (error) {
|
|
130
|
+
const failure = aborted ? abortReason : error;
|
|
131
|
+
cancel(failure);
|
|
132
|
+
throw failure;
|
|
133
|
+
} finally {
|
|
134
|
+
signal?.removeEventListener("abort", onAbort);
|
|
135
|
+
try {
|
|
136
|
+
reader.releaseLock();
|
|
137
|
+
} catch {
|
|
138
|
+
// A pending cancellation can retain the lock briefly; never await it.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
48
143
|
function inflateDeflateBody(compressed: Uint8Array<ArrayBuffer>, opts: { maxOutputLength: number }): Uint8Array {
|
|
49
144
|
// HTTP "deflate" appears both zlib-wrapped and raw in the wild (Bun.deflateSync emits raw,
|
|
50
145
|
// which the previous Bun.inflateSync accepted). Try zlib-wrapped first, fall back to raw —
|
|
@@ -90,24 +185,27 @@ export async function readBoundedJsonRequestBody(
|
|
|
90
185
|
req: Request,
|
|
91
186
|
maxBytes: number,
|
|
92
187
|
budget?: TranslatorBudget,
|
|
188
|
+
options?: { emptyBodyFallback?: unknown },
|
|
93
189
|
): Promise<unknown> {
|
|
94
190
|
const encoding = req.headers.get("content-encoding");
|
|
95
191
|
const declaredLength = declaredBodyLength(req);
|
|
96
|
-
// Reject an honest oversized declaration before
|
|
97
|
-
//
|
|
98
|
-
// post-read cap below.
|
|
192
|
+
// Reject an honest oversized declaration before reading. Missing, malformed,
|
|
193
|
+
// and dishonest declarations remain bounded by the streaming reader below.
|
|
99
194
|
if (declaredLength !== null && declaredLength > maxBytes) {
|
|
100
|
-
|
|
195
|
+
const error = new DecompressedBodyTooLargeError(declaredLength, maxBytes);
|
|
196
|
+
cancelStreamWithoutWaiting(req.body, error);
|
|
197
|
+
throw error;
|
|
101
198
|
}
|
|
102
199
|
const releaseReservation = budget && declaredLength !== null && declaredLength > 0
|
|
103
200
|
? budget.observeAcceptedRequestCopy(declaredLength)
|
|
104
201
|
: undefined;
|
|
105
202
|
let raw: Uint8Array;
|
|
106
203
|
try {
|
|
107
|
-
raw =
|
|
204
|
+
raw = await readRequestBodyBytesCapped(req.body, maxBytes, req.signal);
|
|
108
205
|
} finally {
|
|
109
206
|
releaseReservation?.();
|
|
110
207
|
}
|
|
208
|
+
assertBodySizeWithinLimit(raw, maxBytes);
|
|
111
209
|
const releaseRaw = budget?.observeAcceptedRequestCopy(raw.byteLength);
|
|
112
210
|
let releaseDecoded: (() => void) | undefined;
|
|
113
211
|
let releaseText: (() => void) | undefined;
|
|
@@ -116,6 +214,9 @@ export async function readBoundedJsonRequestBody(
|
|
|
116
214
|
releaseDecoded = decoded === raw ? undefined : budget?.observeAcceptedRequestCopy(decoded.byteLength);
|
|
117
215
|
const text = new TextDecoder().decode(decoded);
|
|
118
216
|
releaseText = budget?.observeAcceptedRequestCopy(new TextEncoder().encode(text).byteLength);
|
|
217
|
+
if (options && "emptyBodyFallback" in options && text.trim() === "") {
|
|
218
|
+
return options.emptyBodyFallback;
|
|
219
|
+
}
|
|
119
220
|
const parsed = JSON.parse(text);
|
|
120
221
|
budget?.observeAcceptedRequestCopy(new TextEncoder().encode(JSON.stringify(parsed)).byteLength);
|
|
121
222
|
return parsed;
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from "../../combos";
|
|
27
27
|
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
28
28
|
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
29
|
-
import { modelInList, namespacedToolName } from "../../types";
|
|
29
|
+
import { modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
|
|
30
30
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
31
31
|
import {
|
|
32
32
|
forceRefreshOAuthAccessSnapshot,
|
|
@@ -108,7 +108,10 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
108
108
|
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
109
109
|
const freeformToolNames = new Set<string>();
|
|
110
110
|
const toolSearchToolNames = new Set<string>();
|
|
111
|
+
const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
|
|
111
112
|
for (const t of parsed.context.tools ?? []) {
|
|
113
|
+
// Upstream output is untrusted: only restore calls for tools the caller authorized.
|
|
114
|
+
if (!toolAllowed(t)) continue;
|
|
112
115
|
if (t.namespace) {
|
|
113
116
|
const wireName = namespacedToolName(t.namespace, t.name);
|
|
114
117
|
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
@@ -16,7 +16,13 @@ import {
|
|
|
16
16
|
previousResponseReplayFailure,
|
|
17
17
|
rememberResponseState,
|
|
18
18
|
} from "../../responses/state";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
comboRouteDecisionTrace,
|
|
21
|
+
NoEligiblePolicyCandidateError,
|
|
22
|
+
routeConcreteModel,
|
|
23
|
+
routeModel,
|
|
24
|
+
type RouteResult,
|
|
25
|
+
} from "../../router";
|
|
20
26
|
import { evidenceFromBody } from "../../routing/request-evidence";
|
|
21
27
|
import {
|
|
22
28
|
advanceComboAfterFailure,
|
|
@@ -35,6 +41,7 @@ import {
|
|
|
35
41
|
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
36
42
|
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
37
43
|
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
44
|
+
import { enrichOpenCodeZenRateLimitMessage } from "../../providers/opencode-zen-rate-limit";
|
|
38
45
|
import { modelInList, namespacedToolName } from "../../types";
|
|
39
46
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
40
47
|
import {
|
|
@@ -199,7 +206,7 @@ import {
|
|
|
199
206
|
relaySseWithBlockRewrite,
|
|
200
207
|
} from "../sse-payload-rewrite";
|
|
201
208
|
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
|
|
202
|
-
import {
|
|
209
|
+
import { responsesJsonToSseStream } from "../responses-json-events";
|
|
203
210
|
import { guardTerminalEventStream } from "./terminal-guard";
|
|
204
211
|
|
|
205
212
|
/**
|
|
@@ -434,7 +441,11 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
434
441
|
firstAuthCtx.writerGeneration,
|
|
435
442
|
);
|
|
436
443
|
}
|
|
437
|
-
|
|
444
|
+
const deferFirstOutcome = shouldDeferCodexResetDerivedCooldown(
|
|
445
|
+
firstResponse,
|
|
446
|
+
options.deferCodexResetDerivedCooldown,
|
|
447
|
+
);
|
|
448
|
+
const recordFirstOutcome = (): void => {
|
|
438
449
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
439
450
|
...quotaMeta,
|
|
440
451
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
@@ -445,8 +456,10 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
445
456
|
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
446
457
|
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
447
458
|
});
|
|
448
|
-
}
|
|
449
|
-
|
|
459
|
+
};
|
|
460
|
+
// Only a combo reset-derived outcome is deferred. Retry-After, defaults, and
|
|
461
|
+
// ordinary requests must block the first account before the alternate send.
|
|
462
|
+
if (!deferFirstOutcome) recordFirstOutcome();
|
|
450
463
|
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
451
464
|
const retryProvider = applyCodexAuthContextToProvider(
|
|
452
465
|
stripCodexRuntimeProviderFields(route.provider),
|
|
@@ -473,8 +486,9 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
473
486
|
);
|
|
474
487
|
|
|
475
488
|
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
489
|
+
let upstreamResponse: Response;
|
|
476
490
|
try {
|
|
477
|
-
|
|
491
|
+
upstreamResponse = await fetchWithHeaderTimeout(
|
|
478
492
|
request.url,
|
|
479
493
|
{
|
|
480
494
|
method: request.method,
|
|
@@ -489,26 +503,33 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
489
503
|
// dead-host rejection after the credential was seen (#914).
|
|
490
504
|
route.provider.authMode === "forward",
|
|
491
505
|
);
|
|
492
|
-
// A real HTTP response proves the host was reached (#914).
|
|
493
|
-
const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
|
|
494
|
-
if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
|
|
495
|
-
resetUpstreamHostHealth(retryHostKey, null);
|
|
496
|
-
} else {
|
|
497
|
-
resetUpstreamHostHealth(retryHostKey);
|
|
498
|
-
}
|
|
499
|
-
return {
|
|
500
|
-
kind: "retried",
|
|
501
|
-
authCtx: retryAuthCtx,
|
|
502
|
-
request,
|
|
503
|
-
upstreamResponse,
|
|
504
|
-
selectedForwardHeaders: retryHeaders,
|
|
505
|
-
};
|
|
506
506
|
} catch (error) {
|
|
507
507
|
// Attribute the transport failure to the alternate account (already selected).
|
|
508
508
|
return { kind: "transport", error, authCtx: retryAuthCtx };
|
|
509
509
|
} finally {
|
|
510
510
|
request.releaseBodyObservation?.();
|
|
511
511
|
}
|
|
512
|
+
// A real HTTP response proves the host was reached (#914).
|
|
513
|
+
const retryHostKey = upstreamHostHealthKey(route.providerName, safeOriginLabel(request.url));
|
|
514
|
+
if (normalizeUpstreamHostCircuitThreshold(config.upstreamHostCircuitThreshold) > 0) {
|
|
515
|
+
resetUpstreamHostHealth(retryHostKey, null);
|
|
516
|
+
} else {
|
|
517
|
+
resetUpstreamHostHealth(retryHostKey);
|
|
518
|
+
}
|
|
519
|
+
if (deferFirstOutcome && upstreamResponse.ok) {
|
|
520
|
+
// Deferral keeps the first account eligible for a later combo model while an
|
|
521
|
+
// alternate attempt is still fallible. Commit its quota outcome only once the
|
|
522
|
+
// alternate account returns a successful HTTP response; otherwise the combo may
|
|
523
|
+
// still need the first account for its next target.
|
|
524
|
+
recordFirstOutcome();
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
kind: "retried",
|
|
528
|
+
authCtx: retryAuthCtx,
|
|
529
|
+
request,
|
|
530
|
+
upstreamResponse,
|
|
531
|
+
selectedForwardHeaders: retryHeaders,
|
|
532
|
+
};
|
|
512
533
|
}
|
|
513
534
|
|
|
514
535
|
|
|
@@ -630,6 +651,12 @@ export interface HandleResponsesOptions {
|
|
|
630
651
|
inboundWire?: InboundWire;
|
|
631
652
|
/** Internal transport identity for route-scoped upstream compatibility policy. */
|
|
632
653
|
inboundTransport?: "websocket";
|
|
654
|
+
/**
|
|
655
|
+
* Claude replay may add native-main auth so OpenAI sidecars remain available.
|
|
656
|
+
* Strip only that internal credential when the final route is a noncanonical
|
|
657
|
+
* forward destination; final routing can differ from Claude's preflight route.
|
|
658
|
+
*/
|
|
659
|
+
stripClaudeMainAuthForNoncanonicalForward?: boolean;
|
|
633
660
|
/** Internal recursion guard; callers outside this module must not set it. */
|
|
634
661
|
comboAttempt?: boolean;
|
|
635
662
|
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
|
|
@@ -961,6 +988,10 @@ async function applyFinalRouteRequestNormalization(args: {
|
|
|
961
988
|
parsed.options.serviceTier = tier;
|
|
962
989
|
}
|
|
963
990
|
applyServiceTierGate(route.provider, parsed._rawBody, parsed.options);
|
|
991
|
+
if (route.provider.adapter === "openai-responses" && route.provider.supportsServiceTier === false) {
|
|
992
|
+
logCtx.requestedServiceTier = undefined;
|
|
993
|
+
logCtx.requestedSpeedLabel = undefined;
|
|
994
|
+
}
|
|
964
995
|
|
|
965
996
|
{
|
|
966
997
|
const guidance = await multiAgentGuidanceText(parsed, {
|
|
@@ -1062,7 +1093,7 @@ export async function handleComboResponses(
|
|
|
1062
1093
|
const provider = config.providers[target.provider];
|
|
1063
1094
|
if (!provider || provider.disabled === true) return false;
|
|
1064
1095
|
try {
|
|
1065
|
-
const route =
|
|
1096
|
+
const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
|
|
1066
1097
|
return isCanonicalOpenAiForwardProvider(route.provider);
|
|
1067
1098
|
} catch {
|
|
1068
1099
|
return false;
|
|
@@ -1096,7 +1127,7 @@ export async function handleComboResponses(
|
|
|
1096
1127
|
...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
|
|
1097
1128
|
...(logCtx.surface ? { surface: logCtx.surface } : {}),
|
|
1098
1129
|
};
|
|
1099
|
-
const targetRoute =
|
|
1130
|
+
const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
1100
1131
|
const childBody = concreteComboRequestBody(
|
|
1101
1132
|
rawBody,
|
|
1102
1133
|
pick.target,
|
|
@@ -1462,7 +1493,9 @@ async function handleResponsesInner(
|
|
|
1462
1493
|
|
|
1463
1494
|
let route: RouteResult;
|
|
1464
1495
|
try {
|
|
1465
|
-
route =
|
|
1496
|
+
route = options.comboAttempt
|
|
1497
|
+
? routeConcreteModel(config, parsed.modelId)
|
|
1498
|
+
: routeModel(config, parsed.modelId, evidenceFromBody(parsed._rawBody));
|
|
1466
1499
|
logCtx.routeDecision = route.routeDecision;
|
|
1467
1500
|
} catch (err) {
|
|
1468
1501
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
@@ -1700,7 +1733,24 @@ async function handleResponsesInner(
|
|
|
1700
1733
|
parsed.options.promptCacheKey,
|
|
1701
1734
|
route.providerName === "github-copilot" ? getOAuthCredentialApiBaseUrl(route.providerName) : undefined,
|
|
1702
1735
|
);
|
|
1703
|
-
|
|
1736
|
+
let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
1737
|
+
const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true
|
|
1738
|
+
&& adapterProvider.adapter === "openai-responses"
|
|
1739
|
+
&& adapterProvider.authMode === "forward"
|
|
1740
|
+
&& !isCanonicalOpenAiForwardProvider(adapterProvider);
|
|
1741
|
+
if (stripClaudeMainAuth) {
|
|
1742
|
+
releaseCodexAuthContextProbeLease(authCtx);
|
|
1743
|
+
authCtx = { kind: "main", accountId: null };
|
|
1744
|
+
route.provider = stripCodexRuntimeProviderFields(route.provider);
|
|
1745
|
+
adapterProvider = stripCodexRuntimeProviderFields(adapterProvider);
|
|
1746
|
+
selectedForwardHeaders = new Headers(selectedForwardHeaders);
|
|
1747
|
+
selectedForwardHeaders.delete("authorization");
|
|
1748
|
+
selectedForwardHeaders.delete("chatgpt-account-id");
|
|
1749
|
+
delete route.codexAccountMode;
|
|
1750
|
+
delete route.codexAccountId;
|
|
1751
|
+
delete route.codexAccountNamespace;
|
|
1752
|
+
logCtx.provider = route.providerName;
|
|
1753
|
+
}
|
|
1704
1754
|
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
1705
1755
|
logCtx.providerAdapter = adapter.name;
|
|
1706
1756
|
// Ordinary requests receive one durable attempt only after their final initial
|
|
@@ -2437,25 +2487,46 @@ async function handleResponsesInner(
|
|
|
2437
2487
|
&& options.inboundTransport !== "websocket"
|
|
2438
2488
|
&& providerModelResponsesUpstreamStreaming(route.providerName, route.provider, route.modelId) === false
|
|
2439
2489
|
&& route.provider.adapter === "openai-responses") {
|
|
2490
|
+
let completed: Record<string, unknown> | undefined;
|
|
2440
2491
|
try {
|
|
2441
|
-
|
|
2492
|
+
const parsedCompleted = JSON.parse(clientJson) as unknown;
|
|
2493
|
+
if (!parsedCompleted || typeof parsedCompleted !== "object" || Array.isArray(parsedCompleted)) {
|
|
2494
|
+
throw new TypeError("bounded Responses JSON is not an object");
|
|
2495
|
+
}
|
|
2496
|
+
let candidate = parsedCompleted as Record<string, unknown>;
|
|
2442
2497
|
// The bounded-JSON answer bypasses the SSE relay, so it also bypasses
|
|
2443
2498
|
// the SSE item-id rewrite. Apply the same client-facing normalization
|
|
2444
2499
|
// here or this policy would silently disable id repair for the very
|
|
2445
2500
|
// providers that need it (raw record already happened above).
|
|
2446
2501
|
if (hasResponsesItemIdRepair(route.provider.responsesItemIdRepair)) {
|
|
2447
|
-
|
|
2502
|
+
candidate = repairResponsesJsonItemIds(candidate, route.provider.responsesItemIdRepair!, translatorBudget);
|
|
2503
|
+
}
|
|
2504
|
+
completed = candidate;
|
|
2505
|
+
} catch {
|
|
2506
|
+
// Non-JSON despite content-type: fall through to the plain relay.
|
|
2507
|
+
}
|
|
2508
|
+
if (completed) {
|
|
2509
|
+
let stream: ReadableStream<Uint8Array>;
|
|
2510
|
+
try {
|
|
2511
|
+
stream = responsesJsonToSseStream(completed);
|
|
2512
|
+
} catch (error) {
|
|
2513
|
+
if (error instanceof RangeError) {
|
|
2514
|
+
return formatErrorResponse(
|
|
2515
|
+
502,
|
|
2516
|
+
"upstream_error",
|
|
2517
|
+
"upstream JSON response exceeded the synthesized SSE item limit",
|
|
2518
|
+
);
|
|
2519
|
+
}
|
|
2520
|
+
throw error;
|
|
2448
2521
|
}
|
|
2449
2522
|
const sseHeaders = sanitizePassthroughHeaders(headers);
|
|
2450
2523
|
sseHeaders.set("content-type", "text/event-stream");
|
|
2451
2524
|
sseHeaders.set("cache-control", "no-store");
|
|
2452
|
-
return new Response(
|
|
2525
|
+
return new Response(stream, {
|
|
2453
2526
|
status: upstreamResponse.status,
|
|
2454
2527
|
statusText: upstreamResponse.statusText,
|
|
2455
2528
|
headers: sseHeaders,
|
|
2456
2529
|
});
|
|
2457
|
-
} catch {
|
|
2458
|
-
// Non-JSON despite content-type: fall through to the plain relay.
|
|
2459
2530
|
}
|
|
2460
2531
|
}
|
|
2461
2532
|
// WS turns reframe this JSON into events in the bridge, which is the
|
|
@@ -3154,11 +3225,26 @@ async function handleResponsesInner(
|
|
|
3154
3225
|
}
|
|
3155
3226
|
// Upstreams occasionally echo request details in error bodies — scrub token-shaped
|
|
3156
3227
|
// material before it reaches the client-facing error surface.
|
|
3157
|
-
const
|
|
3228
|
+
const upstreamRetryAfter = upstreamResponse.headers.get("retry-after");
|
|
3229
|
+
const message = enrichOpenCodeZenRateLimitMessage(
|
|
3230
|
+
`Provider error ${upstreamResponse.status}: ${redactSecretString(errorText.slice(0, 500))}`,
|
|
3231
|
+
{
|
|
3232
|
+
status: upstreamResponse.status,
|
|
3233
|
+
providerName: route.providerName,
|
|
3234
|
+
baseUrl: route.provider.baseUrl,
|
|
3235
|
+
adapter: route.provider.adapter,
|
|
3236
|
+
authMode: route.provider.authMode,
|
|
3237
|
+
hasApiKey: Boolean(route.provider.apiKey?.trim()),
|
|
3238
|
+
upstreamRetryAfter,
|
|
3239
|
+
// This recovery path is the HTTP Responses wire; custom runTurn transports
|
|
3240
|
+
// never reach enrichOpenCodeZenRateLimitMessage here.
|
|
3241
|
+
supportsHttpSameKeyRetry: true,
|
|
3242
|
+
},
|
|
3243
|
+
);
|
|
3158
3244
|
const retryAfter = resolveClientRetryAfter({
|
|
3159
3245
|
status: upstreamResponse.status,
|
|
3160
3246
|
message,
|
|
3161
|
-
upstreamRetryAfter
|
|
3247
|
+
upstreamRetryAfter,
|
|
3162
3248
|
});
|
|
3163
3249
|
return formatErrorResponse(upstreamResponse.status, "upstream_error", message, {
|
|
3164
3250
|
...(retryAfter !== undefined ? { retryAfter } : {}),
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
export type ResponsesJsonEventFrame = Record<string, unknown>;
|
|
9
9
|
|
|
10
|
+
export const MAX_SYNTHESIZED_OUTPUT_ITEMS = 10_000;
|
|
11
|
+
|
|
10
12
|
/**
|
|
11
13
|
* The canonical minimal sequence Codex commits: response.created (empty
|
|
12
14
|
* output, in_progress) → one response.output_item.done per output item → a
|
|
@@ -16,26 +18,38 @@ export function responsesJsonEventSequence(
|
|
|
16
18
|
response: Record<string, unknown>,
|
|
17
19
|
rewritePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
|
|
18
20
|
): ResponsesJsonEventFrame[] {
|
|
21
|
+
return [...iterateResponsesJsonEvents(response, rewritePayload)];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function* iterateResponsesJsonEvents(
|
|
25
|
+
response: Record<string, unknown>,
|
|
26
|
+
rewritePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
|
|
27
|
+
): Generator<ResponsesJsonEventFrame> {
|
|
19
28
|
const rewrite = rewritePayload ?? ((payload: Record<string, unknown>) => payload);
|
|
20
29
|
const output = Array.isArray(response.output) ? response.output : [];
|
|
30
|
+
if (output.length > MAX_SYNTHESIZED_OUTPUT_ITEMS) {
|
|
31
|
+
throw new RangeError(
|
|
32
|
+
`Responses JSON output contains ${output.length} items; maximum is ${MAX_SYNTHESIZED_OUTPUT_ITEMS}`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
21
35
|
const finalStatus = response.status === "failed" || response.status === "incomplete"
|
|
22
36
|
? response.status
|
|
23
37
|
: "completed";
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
yield rewrite({
|
|
39
|
+
type: "response.created",
|
|
40
|
+
response: { ...response, status: "in_progress", output: [] },
|
|
41
|
+
});
|
|
42
|
+
for (const [outputIndex, item] of output.entries()) {
|
|
43
|
+
yield rewrite({
|
|
30
44
|
type: "response.output_item.done",
|
|
31
45
|
output_index: outputIndex,
|
|
32
46
|
item,
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
yield rewrite({
|
|
50
|
+
type: `response.${finalStatus}`,
|
|
51
|
+
response: { ...response, status: finalStatus },
|
|
52
|
+
});
|
|
39
53
|
}
|
|
40
54
|
|
|
41
55
|
/**
|
|
@@ -50,3 +64,27 @@ export function responsesJsonToSseBody(
|
|
|
50
64
|
.map(frame => `data: ${JSON.stringify(frame)}\n\n`);
|
|
51
65
|
return `${frames.join("")}data: [DONE]\n\n`;
|
|
52
66
|
}
|
|
67
|
+
|
|
68
|
+
/** Stream synthesized SSE frames without retaining the expanded body in memory. */
|
|
69
|
+
export function responsesJsonToSseStream(
|
|
70
|
+
response: Record<string, unknown>,
|
|
71
|
+
rewritePayload?: (payload: Record<string, unknown>) => Record<string, unknown>,
|
|
72
|
+
): ReadableStream<Uint8Array> {
|
|
73
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
74
|
+
if (output.length > MAX_SYNTHESIZED_OUTPUT_ITEMS) {
|
|
75
|
+
throw new RangeError(
|
|
76
|
+
`Responses JSON output contains ${output.length} items; maximum is ${MAX_SYNTHESIZED_OUTPUT_ITEMS}`,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
const frames = iterateResponsesJsonEvents(response, rewritePayload);
|
|
80
|
+
const encoder = new TextEncoder();
|
|
81
|
+
return new ReadableStream({
|
|
82
|
+
pull(controller) {
|
|
83
|
+
const next = frames.next();
|
|
84
|
+
controller.enqueue(encoder.encode(
|
|
85
|
+
next.done ? "data: [DONE]\n\n" : `data: ${JSON.stringify(next.value)}\n\n`,
|
|
86
|
+
));
|
|
87
|
+
if (next.done) controller.close();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|