@yansigit/opencodex 2.33.0 → 2.35.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 +3 -3
- package/gui/dist/assets/index-BjCaHxdz.js +112 -0
- package/gui/dist/assets/index-DLkXOXLC.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +79 -2
- package/src/adapters/command-code.ts +141 -23
- package/src/adapters/cursor/call-id.ts +44 -0
- package/src/adapters/cursor/checkpoint-store.ts +15 -10
- package/src/adapters/cursor/discovery.ts +60 -2
- package/src/adapters/cursor/effort-map.ts +79 -1
- package/src/adapters/cursor/envelope-echo.ts +162 -0
- package/src/adapters/cursor/live-models.ts +7 -2
- package/src/adapters/cursor/live-transport.ts +17 -1
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-fs.ts +13 -12
- package/src/adapters/cursor/native-exec-network.ts +3 -5
- package/src/adapters/cursor/native-exec-policy.ts +47 -0
- package/src/adapters/cursor/native-exec-shell.ts +116 -31
- package/src/adapters/cursor/native-exec.ts +38 -10
- package/src/adapters/cursor/protobuf-events.ts +28 -2
- package/src/adapters/cursor/protobuf-request.ts +93 -41
- package/src/adapters/cursor/request-builder.ts +39 -10
- package/src/adapters/cursor/tool-definitions.ts +27 -3
- package/src/adapters/cursor/tool-result-normalize.ts +51 -6
- package/src/adapters/cursor/types.ts +23 -4
- package/src/adapters/cursor.ts +170 -29
- package/src/adapters/google-aistudio-parser.ts +49 -0
- package/src/adapters/google-antigravity-replay.ts +105 -25
- package/src/adapters/google-antigravity-wire.ts +5 -0
- package/src/adapters/google-errors.ts +41 -12
- package/src/adapters/google-http.ts +12 -11
- package/src/adapters/google.ts +219 -36
- package/src/adapters/image.ts +1 -1
- package/src/adapters/kiro-constants.ts +15 -0
- package/src/adapters/kiro-tools.ts +43 -15
- package/src/adapters/kiro.ts +54 -9
- package/src/adapters/openai-chat.ts +286 -242
- package/src/adapters/openai-responses.ts +335 -24
- package/src/adapters/run-turn-queue.ts +36 -1
- package/src/adapters/tool-catalog-nudge.ts +2 -2
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/bridge.ts +67 -26
- package/src/chat/inbound.ts +29 -1
- package/src/chat/outbound.ts +15 -7
- package/src/claude/agents-inject.ts +8 -1
- package/src/claude/outbound.ts +10 -8
- package/src/cli/account-api.ts +27 -7
- package/src/cli/account-extended.ts +10 -3
- package/src/cli/account.ts +29 -5
- package/src/cli/alias.ts +66 -0
- package/src/cli/claude.ts +26 -1
- package/src/cli/dispatch.ts +13 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +6 -1
- package/src/cli/init.ts +1 -0
- package/src/cli/models-runtime.ts +95 -0
- package/src/cli/models.ts +13 -7
- package/src/cli/provider-runtime.ts +16 -2
- package/src/cli/registry.ts +6 -1
- package/src/cli/telemetry-commands.ts +25 -0
- package/src/cli/v2.ts +34 -10
- package/src/codex/account-pause.ts +2 -1
- package/src/codex/account-priority.ts +3 -2
- package/src/codex/app-server-processes.ts +80 -6
- package/src/codex/auth-api.ts +48 -8
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/catalog/aggregation.ts +6 -0
- package/src/codex/catalog/model-metadata.ts +13 -1
- package/src/codex/catalog/native-models.ts +5 -2
- package/src/codex/catalog/parsing.ts +16 -0
- package/src/codex/catalog/provider-fetch.ts +20 -3
- package/src/codex/catalog/sync.ts +127 -2
- package/src/codex/catalog.ts +1 -1
- package/src/codex/codex-write-lock.ts +3 -1
- package/src/codex/convergence-types.ts +1 -1
- package/src/codex/convergence.ts +22 -2
- package/src/codex/desired-state.ts +2 -2
- package/src/codex/desktop-app-restart.ts +18 -5
- package/src/codex/inject-coordination.ts +83 -0
- package/src/codex/inject.ts +14 -1
- package/src/codex/log-guard/inspect.ts +22 -4
- package/src/codex/model-entitlements.ts +9 -2
- package/src/codex/prompt-layers.ts +371 -25
- package/src/codex/prompt-text-probe.ts +238 -0
- package/src/codex/quota.ts +123 -18
- package/src/codex/routing.ts +9 -0
- package/src/codex/subagent-model-fallback.ts +198 -27
- package/src/codex/transition-state.ts +107 -8
- package/src/combos/types.ts +10 -0
- package/src/compatibility/openai-responses.ts +33 -1
- package/src/config/autonomous-remediation.ts +21 -0
- package/src/config/provider-validation.ts +14 -0
- package/src/config/rebase-provenance.ts +68 -0
- package/src/config.ts +191 -17
- package/src/generated/compatibility-version.json +279 -159
- package/src/generated/model-metadata.ts +3 -0
- package/src/images/loop.ts +5 -4
- package/src/lab/conformance/fixtures/protocol-v1-cases.json +1 -1
- package/src/lab/fabric/producer-child.ts +1 -1
- package/src/lib/config-ownership.ts +20 -0
- package/src/lib/errors.ts +11 -2
- package/src/lib/package-tree-integrity.ts +101 -0
- package/src/oauth/aistudio-credentials.ts +65 -0
- package/src/oauth/aistudio-native-daemon.ts +116 -0
- package/src/oauth/aistudio-session-sync.ts +95 -0
- package/src/oauth/generic-account-failover.ts +231 -0
- package/src/oauth/google-aistudio-auth.ts +98 -0
- package/src/oauth/index.ts +57 -5
- package/src/oauth/key-providers.ts +18 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/login-cli.ts +65 -1
- package/src/oauth/types.ts +15 -0
- package/src/providers/codex-capacity.ts +5 -2
- package/src/providers/command-code-efforts.ts +38 -6
- package/src/providers/context-cap.ts +4 -3
- package/src/providers/default-aliases.ts +65 -0
- package/src/providers/derive.ts +29 -1
- package/src/providers/fastwire.ts +7 -1
- package/src/providers/model-presets.ts +119 -0
- package/src/providers/new-model-policy.ts +146 -0
- package/src/providers/provider-id-rewrite.ts +2 -1
- package/src/providers/quota.ts +157 -46
- package/src/providers/registry.ts +184 -71
- package/src/providers/slug-codec.ts +52 -0
- package/src/responses/code-mode-helper-compat.ts +50 -0
- package/src/responses/custom-tool-compat.ts +34 -10
- package/src/responses/parser.ts +4 -0
- package/src/responses/schema.ts +5 -1
- package/src/responses/thought-signature-replay.ts +17 -0
- package/src/router.ts +43 -2
- package/src/routing/account-pool/cooldown.ts +8 -0
- package/src/routing/account-pool/index.ts +1 -0
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +24 -0
- package/src/server/chat-completions.ts +26 -16
- package/src/server/chat-native-sse.ts +3 -3
- package/src/server/chat-native.ts +30 -11
- package/src/server/claude-messages.ts +1 -1
- package/src/server/effort-policy.ts +16 -0
- package/src/server/index.ts +180 -14
- package/src/server/lifecycle.ts +52 -1
- package/src/server/management/agent-settings-routes.ts +31 -15
- package/src/server/management/codex-prompt-routes.ts +570 -0
- package/src/server/management/combo-routes.ts +2 -1
- package/src/server/management/config-routes.ts +27 -9
- package/src/server/management/context.ts +9 -0
- package/src/server/management/logs-usage-routes.ts +11 -5
- package/src/server/management/model-routes.ts +266 -0
- package/src/server/management/oauth-account-routes.ts +13 -3
- package/src/server/management/provider-routes.ts +137 -3
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management-api.ts +2 -0
- package/src/server/port-reclaim.ts +19 -1
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +33 -0
- package/src/server/request-log.ts +48 -21
- package/src/server/responses/collaboration.ts +42 -5
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/core.ts +575 -140
- package/src/server/responses/empty-completion-guard.ts +35 -0
- package/src/server/responses/fetch-helpers.ts +14 -6
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/policy-fallback.ts +1 -1
- package/src/server/responses/responses-field-backfill.ts +105 -13
- package/src/server/responses/ws-upstream.ts +35 -5
- package/src/server/responses-custom-tool-repair.ts +52 -7
- package/src/server/responses-terminal-repair.ts +25 -4
- package/src/server/sse-frame-buffer.ts +31 -4
- package/src/server/ws-bridge.ts +14 -2
- package/src/smoke/fingerprint-cache.ts +133 -0
- package/src/smoke/live-scenarios.ts +33 -0
- package/src/smoke/runner.ts +119 -0
- package/src/telemetry/dispatcher.ts +44 -0
- package/src/telemetry/fingerprint.ts +24 -0
- package/src/telemetry/hook.ts +43 -0
- package/src/telemetry/ledger.ts +54 -0
- package/src/telemetry/types.ts +23 -0
- package/src/types/config.ts +66 -14
- package/src/types/provider.ts +79 -1
- package/src/types/request.ts +18 -10
- package/src/types/tools.ts +30 -11
- package/src/types.ts +1 -0
- package/src/usage/command-code-manifest.ts +116 -0
- package/src/usage/cost.ts +2 -2
- package/src/usage/expected-prices.ts +126 -24
- package/src/usage/log.ts +18 -8
- package/src/usage/summary.ts +34 -12
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/index.ts +16 -8
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-DKLr4LTE.js +0 -102
- package/gui/dist/assets/index-DrSQdTRd.css +0 -1
|
@@ -35,6 +35,41 @@ export function emptyCompletionRetryEnabled(
|
|
|
35
35
|
/** Surfaced when the single retry was also empty or failed upstream. */
|
|
36
36
|
export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed";
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Observe an event stream for the empty-completion shape WITHOUT changing it (#2472).
|
|
40
|
+
*
|
|
41
|
+
* The guard above is opt-in, so with the default configuration a turn that completes with no
|
|
42
|
+
* output text and no tool call passes through untouched and the client records a silent
|
|
43
|
+
* success. That is the reported symptom: an empty result nobody can explain, with no trace
|
|
44
|
+
* that the proxy saw anything unusual.
|
|
45
|
+
*
|
|
46
|
+
* This is deliberately a passthrough observer, not a second guard. Retrying by default would
|
|
47
|
+
* re-send a turn that may have already had billable side effects; the honest default is to
|
|
48
|
+
* leave the stream alone and make the occurrence visible, so a user can correlate it and
|
|
49
|
+
* decide whether to enable the retry.
|
|
50
|
+
*/
|
|
51
|
+
export async function* observeEmptyCompletion(
|
|
52
|
+
events: AsyncIterable<AdapterEvent>,
|
|
53
|
+
onEmptyTurn: () => void,
|
|
54
|
+
): AsyncGenerator<AdapterEvent> {
|
|
55
|
+
let sawContent = false;
|
|
56
|
+
let sawTerminal = false;
|
|
57
|
+
for await (const event of events) {
|
|
58
|
+
// Reasoning is deliberately NOT content, matching the guard: a reasoning-only stream that
|
|
59
|
+
// ends with nothing is the canonical shape of this failure.
|
|
60
|
+
if (isContentEvent(event)) sawContent = true;
|
|
61
|
+
if (isTerminalEvent(event)) {
|
|
62
|
+
sawTerminal = true;
|
|
63
|
+
// Only a successful terminal is the silent failure. `error` and `incomplete` are already
|
|
64
|
+
// a stated outcome the client can render, so flagging them would be noise.
|
|
65
|
+
if (!sawContent && event.type === "done") onEmptyTurn();
|
|
66
|
+
}
|
|
67
|
+
yield event;
|
|
68
|
+
}
|
|
69
|
+
// A stream that ends before any terminal is the pre-output EOF variant of the same failure.
|
|
70
|
+
if (!sawContent && !sawTerminal) onEmptyTurn();
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
/**
|
|
39
74
|
* Terminal stop reasons the bridge renders as a visible `response.incomplete`
|
|
40
75
|
* (max_tokens / content_filter). Those are already a stated failure, not the
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
codexWsUpstreamFetch,
|
|
4
4
|
currentBunRuntimeIdentity,
|
|
5
5
|
shouldUseCodexWsUpstream,
|
|
6
|
+
type CodexWsUpstreamOptions,
|
|
6
7
|
type BunRuntimeGateInput,
|
|
7
8
|
} from "./ws-upstream";
|
|
8
9
|
import type { OcxProviderConfig } from "../../types";
|
|
@@ -73,19 +74,25 @@ export function providerFetch(
|
|
|
73
74
|
: base;
|
|
74
75
|
const httpFetch = Object.assign(
|
|
75
76
|
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
|
|
76
|
-
transport(input, withUpstreamHttpVersion(input, init, provider)),
|
|
77
|
+
transport(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }),
|
|
77
78
|
{ preconnect },
|
|
78
79
|
) as typeof globalThis.fetch;
|
|
79
|
-
// ChatGPT Codex backend: streaming turns
|
|
80
|
-
//
|
|
81
|
-
//
|
|
80
|
+
// ChatGPT Codex backend: eligible streaming turns stay on HTTP/SSE by
|
|
81
|
+
// default. `wsUpstream: true`, or (when that option is omitted)
|
|
82
|
+
// OCX_CODEX_WS_UPSTREAM=true/1, opts into the responses_websockets transport;
|
|
83
|
+
// everything else keeps the provider's HTTP fetch. See ws-upstream.ts for
|
|
84
|
+
// the details.
|
|
85
|
+
const wsOptions: CodexWsUpstreamOptions = {
|
|
86
|
+
wsUpstream: provider.wsUpstream,
|
|
87
|
+
maxWsFrameBytes: provider.maxWsFrameBytes,
|
|
88
|
+
};
|
|
82
89
|
const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
|
|
83
|
-
if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
|
|
90
|
+
if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime, wsOptions)) {
|
|
84
91
|
// The fallback has to be the same HTTP fetch the non-WS branch would have
|
|
85
92
|
// used, protocol pin included: a WS turn that falls back is serving the
|
|
86
93
|
// request over HTTP, and dropping the provider's `upstreamHttpVersion`
|
|
87
94
|
// there would silently negotiate a transport the operator ruled out.
|
|
88
|
-
return codexWsUpstreamFetch(input, init, httpFetch, runtime);
|
|
95
|
+
return codexWsUpstreamFetch(input, init, httpFetch, runtime, wsOptions);
|
|
89
96
|
}
|
|
90
97
|
return httpFetch(input, init);
|
|
91
98
|
};
|
|
@@ -143,6 +150,7 @@ export async function fetchWithHeaderTimeout(
|
|
|
143
150
|
// indistinguishable from a pre-connection failure (#914).
|
|
144
151
|
...(manualRedirect ? { redirect: "manual" as const } : {}),
|
|
145
152
|
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
153
|
+
timeout: 0,
|
|
146
154
|
});
|
|
147
155
|
} finally {
|
|
148
156
|
clearTimeout(timer);
|
|
@@ -68,7 +68,9 @@ function imageTokens(imageUrl: string): number {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
71
|
-
|
|
71
|
+
if (part.type === "image") return imageTokens(part.imageUrl);
|
|
72
|
+
if (part.type === "video") return imageTokens(part.videoUrl);
|
|
73
|
+
return estimateTokens(part.text, modelId);
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { formatErrorResponse } from "../../bridge";
|
|
2
|
+
import { isCyberPolicyCode, isCyberPolicyMessage } from "../../lib/errors";
|
|
2
3
|
import {
|
|
3
4
|
resolveClientRetryAfter,
|
|
4
5
|
validateClientRetryAfterHeader,
|
|
5
6
|
} from "../../lib/retry-after";
|
|
6
7
|
|
|
8
|
+
function isCyberPolicyBody(body: string): boolean {
|
|
9
|
+
if (isCyberPolicyMessage(body)) return true;
|
|
10
|
+
try {
|
|
11
|
+
const parsed = JSON.parse(body) as Record<string, unknown>;
|
|
12
|
+
const response = parsed.response && typeof parsed.response === "object" && !Array.isArray(parsed.response)
|
|
13
|
+
? parsed.response as Record<string, unknown>
|
|
14
|
+
: undefined;
|
|
15
|
+
for (const candidate of [parsed.error, response?.error, response?.last_error, parsed.last_error, parsed]) {
|
|
16
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
|
17
|
+
const record = candidate as Record<string, unknown>;
|
|
18
|
+
if (isCyberPolicyCode(typeof record.code === "string" ? record.code : undefined)) return true;
|
|
19
|
+
if (typeof record.message === "string" && isCyberPolicyMessage(record.message)) return true;
|
|
20
|
+
}
|
|
21
|
+
} catch {
|
|
22
|
+
/* non-JSON body — message detection above is the only safe fallback */
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
7
27
|
/**
|
|
8
28
|
* Passthrough adapters historically relayed upstream non-2xx bodies verbatim.
|
|
9
29
|
* Codex maps an *empty* body to the literal client string "Unknown error"
|
|
@@ -32,18 +52,22 @@ export function formatPassthroughUpstreamError(
|
|
|
32
52
|
const now = options?.now ?? Date.now();
|
|
33
53
|
const upstreamRetryAfter = options?.headers?.get("retry-after")?.trim() || undefined;
|
|
34
54
|
const originalValid = validateClientRetryAfterHeader(upstreamRetryAfter, now);
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
55
|
+
const cyberPolicyFailure = isCyberPolicyBody(trimmed);
|
|
56
|
+
const resolved = cyberPolicyFailure
|
|
57
|
+
? undefined
|
|
58
|
+
: resolveClientRetryAfter({
|
|
59
|
+
status,
|
|
60
|
+
message: trimmed || `Provider error ${status}: (empty body)`,
|
|
61
|
+
upstreamRetryAfter,
|
|
62
|
+
now,
|
|
63
|
+
});
|
|
41
64
|
|
|
42
65
|
if (trimmed) {
|
|
43
66
|
const needsSet = resolved !== undefined && upstreamRetryAfter !== resolved;
|
|
44
|
-
const needsDelete =
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
const needsDelete = (cyberPolicyFailure && upstreamRetryAfter !== undefined)
|
|
68
|
+
|| (resolved === undefined
|
|
69
|
+
&& upstreamRetryAfter !== undefined
|
|
70
|
+
&& originalValid === undefined);
|
|
47
71
|
|
|
48
72
|
if (!needsSet && !needsDelete) {
|
|
49
73
|
return new Response(bodyText, {
|
|
@@ -89,7 +89,7 @@ function finishFailedPolicyAttempt(logCtx: RequestLogContext, status: number): v
|
|
|
89
89
|
const attempt = logCtx.activeAttempt;
|
|
90
90
|
if (attempt) {
|
|
91
91
|
const startedAt = logCtx.activeAttemptStartedAt ?? Date.now();
|
|
92
|
-
finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage);
|
|
92
|
+
finishRequestAttempt(attempt, status, Math.max(0, Date.now() - startedAt), attempt.usage ?? logCtx.usage, logCtx.upstreamError);
|
|
93
93
|
}
|
|
94
94
|
delete logCtx.activeAttempt;
|
|
95
95
|
delete logCtx.activeAttemptStartedAt;
|
|
@@ -91,6 +91,29 @@ function nextSyntheticItemSlot(): ItemIdSlot {
|
|
|
91
91
|
return { kind: "fallback", ordinal: syntheticItemOrdinal };
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Backfill `status` on a message output item if missing.
|
|
96
|
+
*
|
|
97
|
+
* The Responses API spec defines `status` as a required field on
|
|
98
|
+
* `OutputMessage`. Some upstream relays omit it, which causes strict
|
|
99
|
+
* deserializers (e.g. grok-build's serde types) to fail with
|
|
100
|
+
* `missing field 'status'`. Only message items carry this field in the
|
|
101
|
+
* Responses schema; reasoning, function_call, and other item types do not.
|
|
102
|
+
*
|
|
103
|
+
* The value is inferred from the event context: `output_item.added` and
|
|
104
|
+
* `response.created` / `response.in_progress` mean the message is still
|
|
105
|
+
* being generated (`in_progress`); `output_item.done` and
|
|
106
|
+
* `response.completed` / `response.incomplete` mean the message is
|
|
107
|
+
* finalized (`completed` / `incomplete` respectively).
|
|
108
|
+
*
|
|
109
|
+
* Returns the same object reference if no change is needed.
|
|
110
|
+
*/
|
|
111
|
+
function backfillItemStatus(item: Record<string, unknown>, inferredStatus: string): Record<string, unknown> {
|
|
112
|
+
if (item.type !== "message") return item;
|
|
113
|
+
if ("status" in item) return item;
|
|
114
|
+
return { ...item, status: inferredStatus };
|
|
115
|
+
}
|
|
116
|
+
|
|
94
117
|
/**
|
|
95
118
|
* Backfill annotations: [] on an output_text content part if missing.
|
|
96
119
|
* Returns the same object reference if no change is needed.
|
|
@@ -122,10 +145,10 @@ function backfillContentArray(content: unknown): unknown {
|
|
|
122
145
|
|
|
123
146
|
/**
|
|
124
147
|
* Walk an output item and backfill output_text parts in its content.
|
|
125
|
-
* Also backfills a missing required id on the item itself.
|
|
148
|
+
* Also backfills a missing required id and status on the item itself.
|
|
126
149
|
* Returns the same object reference if nothing changed.
|
|
127
150
|
*/
|
|
128
|
-
function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
|
|
151
|
+
function backfillOutputItem(item: unknown, slot: ItemIdSlot, inferredStatus: string): unknown {
|
|
129
152
|
if (!isPlainObject(item)) return item;
|
|
130
153
|
// The compact wire family is the `/v1/responses/compact` format, not a Responses output item.
|
|
131
154
|
// Those items have no `id` in that contract, so synthesizing one changes a response body the
|
|
@@ -136,34 +159,92 @@ function backfillOutputItem(item: unknown, slot: ItemIdSlot): unknown {
|
|
|
136
159
|
const content = item.content;
|
|
137
160
|
const repaired = backfillContentArray(content);
|
|
138
161
|
const withId = backfillItemId(item, slot);
|
|
139
|
-
|
|
140
|
-
|
|
162
|
+
const withStatus = backfillItemStatus(withId, inferredStatus);
|
|
163
|
+
if (repaired === content && withStatus === item) return item;
|
|
164
|
+
return { ...withStatus, ...(repaired === content ? {} : { content: repaired }) };
|
|
141
165
|
}
|
|
142
166
|
|
|
143
167
|
/**
|
|
144
168
|
* Walk a response object's output[] and backfill output_text parts.
|
|
169
|
+
*
|
|
170
|
+
* `inferredItemStatus` is the status to backfill on message items that lack
|
|
171
|
+
* one — derived from the event type so `output_item.added` / `response.created`
|
|
172
|
+
* gets `in_progress` while `output_item.done` / `response.completed` gets
|
|
173
|
+
* `completed`.
|
|
174
|
+
*
|
|
175
|
+
* Deliberately does NOT backfill `created_at`. #2639 proposed it for the same
|
|
176
|
+
* strict-decoder reason as `status`, but the proxy also relays some upstream
|
|
177
|
+
* responses verbatim, and `tests/server-combo-failover-e2e.test.ts` asserts a
|
|
178
|
+
* combo backup response is returned byte-exact. Injecting a field the upstream
|
|
179
|
+
* never sent breaks that contract. Both cannot hold for the same body, so the
|
|
180
|
+
* `created_at` half needs its own decision about which contract yields; it is
|
|
181
|
+
* not a detail to slip in beside `status`.
|
|
182
|
+
*
|
|
145
183
|
* Returns the same object reference if nothing changed.
|
|
146
184
|
*/
|
|
147
|
-
function backfillResponseOutput(response: unknown): unknown {
|
|
185
|
+
function backfillResponseOutput(response: unknown, inferredItemStatus: string): unknown {
|
|
148
186
|
if (!isPlainObject(response)) return response;
|
|
149
187
|
const output = response.output;
|
|
150
188
|
if (!Array.isArray(output)) return response;
|
|
151
189
|
let changed = false;
|
|
152
190
|
const repaired = output.map((item, idx) => {
|
|
153
191
|
if (!isPlainObject(item)) return item;
|
|
154
|
-
const next = backfillOutputItem(item, { kind: "index", index: idx });
|
|
192
|
+
const next = backfillOutputItem(item, { kind: "index", index: idx }, inferredItemStatus);
|
|
155
193
|
if (next !== item) changed = true;
|
|
156
194
|
return next;
|
|
157
195
|
});
|
|
158
196
|
return changed ? { ...response, output: repaired } : response;
|
|
159
197
|
}
|
|
160
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Infer the status to backfill on a message item from the event type.
|
|
201
|
+
*
|
|
202
|
+
* `output_item.added` means the item is still being generated (`in_progress`);
|
|
203
|
+
* `output_item.done` means it is finalized (`completed`). Response-level events
|
|
204
|
+
* infer from the response's own status field — which is authoritative when present.
|
|
205
|
+
* If the response status is also absent, the event type itself determines the phase:
|
|
206
|
+
* `response.created` / `response.in_progress` → `in_progress`,
|
|
207
|
+
* `response.completed` → `completed`, `response.incomplete` → `incomplete`.
|
|
208
|
+
*/
|
|
209
|
+
function inferredStatusForEventType(eventType: string): string {
|
|
210
|
+
if (eventType === "response.output_item.added") return "in_progress";
|
|
211
|
+
if (eventType === "response.output_item.done") return "completed";
|
|
212
|
+
if (eventType === "response.created" || eventType === "response.in_progress") return "in_progress";
|
|
213
|
+
// `queued` is a real Responses lifecycle status: the response exists but has not
|
|
214
|
+
// started generating. Without this row it falls through to the `completed`
|
|
215
|
+
// default below, which would mark an unstarted message as finished.
|
|
216
|
+
if (eventType === "response.queued") return "in_progress";
|
|
217
|
+
if (eventType === "response.incomplete" || eventType === "response.failed") return "incomplete";
|
|
218
|
+
return "completed";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Map a response-level lifecycle status to a valid OutputMessage status.
|
|
223
|
+
*
|
|
224
|
+
* `OutputMessage.status` accepts only `in_progress`, `completed`, or
|
|
225
|
+
* `incomplete`. Response-level statuses like `failed` or `cancelled` have no
|
|
226
|
+
* direct message-level equivalent, but `incomplete` is the correct semantic
|
|
227
|
+
* mapping: the message did not finish generating. Writing `completed` would
|
|
228
|
+
* assert something the upstream never claimed — a client branching on
|
|
229
|
+
* `status === "completed"` would treat a truncated message as whole.
|
|
230
|
+
*/
|
|
231
|
+
function messageStatusFromResponseStatus(status: string): string | null {
|
|
232
|
+
if (status === "in_progress" || status === "completed" || status === "incomplete") return status;
|
|
233
|
+
// A queued response has not begun generating, so its message items are
|
|
234
|
+
// in_progress — never completed. Returning null here would fall back to the
|
|
235
|
+
// event-type inference, whose default is `completed`.
|
|
236
|
+
if (status === "queued") return "in_progress";
|
|
237
|
+
if (status === "failed" || status === "cancelled") return "incomplete";
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
161
241
|
/**
|
|
162
242
|
* Statelessly rewrite one SSE event: backfill annotations
|
|
163
243
|
* on any output_text content part found in the event payload.
|
|
164
244
|
*/
|
|
165
245
|
function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
166
246
|
const type = typeof event.type === "string" ? event.type : "";
|
|
247
|
+
const inferredItemStatus = inferredStatusForEventType(type);
|
|
167
248
|
let next = event;
|
|
168
249
|
let changed = false;
|
|
169
250
|
|
|
@@ -177,8 +258,8 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
|
177
258
|
// is not recoverable in that case, but a unique id is what strict decoders require, and a
|
|
178
259
|
// well-formed stream still gets the stable index-derived id.
|
|
179
260
|
const item = typeof rawIndex === "number" && Number.isInteger(rawIndex) && rawIndex >= 0
|
|
180
|
-
? backfillOutputItem(event.item, { kind: "index", index: rawIndex })
|
|
181
|
-
: backfillOutputItem(event.item, nextSyntheticItemSlot());
|
|
261
|
+
? backfillOutputItem(event.item, { kind: "index", index: rawIndex }, inferredItemStatus)
|
|
262
|
+
: backfillOutputItem(event.item, nextSyntheticItemSlot(), inferredItemStatus);
|
|
182
263
|
if (item !== event.item) {
|
|
183
264
|
next = { ...next, item };
|
|
184
265
|
changed = true;
|
|
@@ -198,7 +279,13 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
|
198
279
|
// response.created / in_progress / completed / incomplete / failed:
|
|
199
280
|
// response.output[].content[] -> output_text parts
|
|
200
281
|
if (isPlainObject(event.response)) {
|
|
201
|
-
|
|
282
|
+
// For response-level events, prefer the response's own status when it is a valid
|
|
283
|
+
// OutputMessage status. Response lifecycle statuses like "failed" or "cancelled"
|
|
284
|
+
// have no message-level equivalent — fall back to the event-type inference instead.
|
|
285
|
+
const responseStatus = typeof event.response.status === "string"
|
|
286
|
+
? messageStatusFromResponseStatus(event.response.status) ?? inferredItemStatus
|
|
287
|
+
: inferredItemStatus;
|
|
288
|
+
const response = backfillResponseOutput(event.response, responseStatus);
|
|
202
289
|
if (response !== event.response) {
|
|
203
290
|
next = { ...next, response };
|
|
204
291
|
changed = true;
|
|
@@ -210,9 +297,9 @@ function rewriteEvent(event: Record<string, unknown>): Record<string, unknown> {
|
|
|
210
297
|
|
|
211
298
|
/**
|
|
212
299
|
* Create a stateless SSE block rewrite that backfills annotations and
|
|
213
|
-
* on output_text content parts
|
|
214
|
-
* canonical Responses
|
|
215
|
-
* clients.
|
|
300
|
+
* message status on output_text content parts and message items.
|
|
301
|
+
* Unconditional: both are required canonical Responses fields, so adding
|
|
302
|
+
* them when absent is safe for all clients.
|
|
216
303
|
*/
|
|
217
304
|
export function createResponsesFieldBackfillBlockRewrite(): SseBlockRewrite {
|
|
218
305
|
const rewrite: SseBlockRewrite = (block: string): readonly string[] => {
|
|
@@ -245,7 +332,12 @@ export function backfillResponsesFieldsJson(payload: string): string {
|
|
|
245
332
|
return payload;
|
|
246
333
|
}
|
|
247
334
|
if (!isPlainObject(response)) return payload;
|
|
248
|
-
|
|
335
|
+
// For a non-streaming response, derive the item status from the response's own
|
|
336
|
+
// status field when it is a valid OutputMessage status; fall back to "completed".
|
|
337
|
+
const inferredItemStatus = typeof response.status === "string"
|
|
338
|
+
? messageStatusFromResponseStatus(response.status) ?? "completed"
|
|
339
|
+
: "completed";
|
|
340
|
+
const repaired = backfillResponseOutput(response, inferredItemStatus);
|
|
249
341
|
if (repaired === response) return payload;
|
|
250
342
|
return JSON.stringify(repaired);
|
|
251
343
|
}
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// a measurably faster queue than the plain SSE POST path. Measured 2026-08-12
|
|
5
5
|
// KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50
|
|
6
6
|
// ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself defaults to the WS
|
|
7
|
-
// transport; opencodex
|
|
8
|
-
//
|
|
7
|
+
// transport; opencodex keeps HTTP/SSE as its reliable default and allows
|
|
8
|
+
// operators to opt into WS when the lower latency is worth the risk.
|
|
9
9
|
//
|
|
10
10
|
// The wrapper only swaps the transport. It dials wss:// with the same headers,
|
|
11
11
|
// sends the JSON body as a single `response.create` frame, and re-encodes the
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
16
16
|
import { compareBunVersions } from "../../lib/bun-stream-caps";
|
|
17
|
+
import { interceptRuntimeFailure } from "../../telemetry/hook";
|
|
17
18
|
|
|
18
19
|
const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
|
|
19
20
|
const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
|
|
@@ -55,6 +56,29 @@ export type BunRuntimeIdentity = {
|
|
|
55
56
|
|
|
56
57
|
export type BunRuntimeGateInput = string | BunRuntimeIdentity;
|
|
57
58
|
|
|
59
|
+
export interface CodexWsUpstreamOptions {
|
|
60
|
+
wsUpstream?: boolean;
|
|
61
|
+
maxWsFrameBytes?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isCodexWsUpstreamDisabled(options?: CodexWsUpstreamOptions): boolean {
|
|
65
|
+
if (options?.wsUpstream !== undefined) return options.wsUpstream !== true;
|
|
66
|
+
const env = process.env.OCX_CODEX_WS_UPSTREAM;
|
|
67
|
+
return env !== "true" && env !== "1";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function resolveCodexWsMaxFrameBytes(options?: CodexWsUpstreamOptions): number {
|
|
71
|
+
if (typeof options?.maxWsFrameBytes === "number" && Number.isFinite(options.maxWsFrameBytes) && options.maxWsFrameBytes > 0) {
|
|
72
|
+
return Math.min(options.maxWsFrameBytes, CODEX_WS_CREATE_FRAME_LIMIT_BYTES);
|
|
73
|
+
}
|
|
74
|
+
const envVal = process.env.OCX_CODEX_WS_MAX_FRAME_BYTES;
|
|
75
|
+
if (envVal) {
|
|
76
|
+
const parsed = Number.parseInt(envVal, 10);
|
|
77
|
+
if (Number.isFinite(parsed) && parsed > 0) return Math.min(parsed, CODEX_WS_CREATE_FRAME_LIMIT_BYTES);
|
|
78
|
+
}
|
|
79
|
+
return CODEX_WS_CREATE_FRAME_LIMIT_BYTES;
|
|
80
|
+
}
|
|
81
|
+
|
|
58
82
|
const codexWsUpstreamResponses = new WeakSet<Response>();
|
|
59
83
|
|
|
60
84
|
/** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
|
|
@@ -102,7 +126,9 @@ export function shouldUseCodexWsUpstream(
|
|
|
102
126
|
url: string,
|
|
103
127
|
init?: RequestInit,
|
|
104
128
|
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
129
|
+
options?: CodexWsUpstreamOptions,
|
|
105
130
|
): boolean {
|
|
131
|
+
if (isCodexWsUpstreamDisabled(options)) return false;
|
|
106
132
|
if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
|
|
107
133
|
if (url !== CODEX_RESPONSES_HTTP_URL) return false;
|
|
108
134
|
if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
|
|
@@ -172,8 +198,9 @@ export function codexWsUpstreamFetch(
|
|
|
172
198
|
init: RequestInit,
|
|
173
199
|
sseFallback: typeof globalThis.fetch,
|
|
174
200
|
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
201
|
+
options?: CodexWsUpstreamOptions,
|
|
175
202
|
): Promise<Response> {
|
|
176
|
-
if (!bunSupportsBoundedCodexWsRelay(runtime)) {
|
|
203
|
+
if (isCodexWsUpstreamDisabled(options) || !bunSupportsBoundedCodexWsRelay(runtime)) {
|
|
177
204
|
return sseFallback(url, init);
|
|
178
205
|
}
|
|
179
206
|
const signal = init.signal ?? undefined;
|
|
@@ -196,7 +223,8 @@ export function codexWsUpstreamFetch(
|
|
|
196
223
|
// streaming Response, so the oversized close can only be surfaced as a stream
|
|
197
224
|
// error — and a resend at that point could double-generate. Measuring the
|
|
198
225
|
// frame we are about to send keeps the whole failure mode unreachable.
|
|
199
|
-
|
|
226
|
+
const maxFrameBytes = resolveCodexWsMaxFrameBytes(options);
|
|
227
|
+
if (codexWsCreateFrameExceedsLimit(frameText, maxFrameBytes)) {
|
|
200
228
|
return sseFallback(url, init);
|
|
201
229
|
}
|
|
202
230
|
|
|
@@ -370,7 +398,9 @@ export function codexWsUpstreamFetch(
|
|
|
370
398
|
// here would reach clients with no response.completed/failed at all —
|
|
371
399
|
// relaySseWithFailedTail() only synthesizes a failed terminal when the
|
|
372
400
|
// body read THROWS. Error the stream like a reset TCP socket.
|
|
373
|
-
|
|
401
|
+
const error = new Error(closedBeforeTerminalMessage(event));
|
|
402
|
+
if ((event as { code?: unknown } | null)?.code === 1006) interceptRuntimeFailure(error, { category: "websocket_1006" });
|
|
403
|
+
try { controller.error(error); } catch { /* stream already done */ }
|
|
374
404
|
}
|
|
375
405
|
});
|
|
376
406
|
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import type { TranslatorBudget } from "../lib/translator-budget";
|
|
2
2
|
import { normalizeApplyPatchDelimiters } from "../responses/apply-patch-envelope";
|
|
3
|
+
import { compileCodeModeHelperInput } from "../responses/code-mode-helper-compat";
|
|
3
4
|
import {
|
|
4
5
|
customToolItemId,
|
|
5
6
|
restoreRoutedCustomCalls,
|
|
7
|
+
routedCustomToolTargetName,
|
|
6
8
|
routedCustomToolWireName,
|
|
7
9
|
unwrapRoutedCustomToolArguments,
|
|
8
10
|
} from "../responses/custom-tool-compat";
|
|
@@ -87,8 +89,10 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
87
89
|
names: ReadonlySet<string>,
|
|
88
90
|
budget?: TranslatorBudget,
|
|
89
91
|
repairNames: ReadonlySet<string> = new Set(),
|
|
92
|
+
declaredNames?: ReadonlySet<string>,
|
|
90
93
|
): SseBlockRewrite {
|
|
91
|
-
const itemNames = new Map<string, { name: string; namespace?: string }>();
|
|
94
|
+
const itemNames = new Map<string, { name: string; aliased: boolean; namespace?: string }>();
|
|
95
|
+
const customAliasItemNames = new Map<string, string>();
|
|
92
96
|
const repairItemNames = new Map<string, string>();
|
|
93
97
|
const ordinaryItemIds = new Set<string>();
|
|
94
98
|
const openCalls = new Map<string, OpenCustomCall>();
|
|
@@ -114,6 +118,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
114
118
|
}
|
|
115
119
|
pendingArguments = [];
|
|
116
120
|
itemNames.clear();
|
|
121
|
+
customAliasItemNames.clear();
|
|
117
122
|
repairItemNames.clear();
|
|
118
123
|
ordinaryItemIds.clear();
|
|
119
124
|
};
|
|
@@ -187,11 +192,20 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
187
192
|
) {
|
|
188
193
|
const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
|
|
189
194
|
const wireName = routedCustomToolWireName(parsed.item);
|
|
195
|
+
const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames);
|
|
196
|
+
const aliased = targetName !== undefined && targetName !== wireName;
|
|
197
|
+
if (upstreamItemId && aliased) {
|
|
198
|
+
customAliasItemNames.set(upstreamItemId, parsed.item.name);
|
|
199
|
+
if (type === "response.output_item.added") {
|
|
200
|
+
openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
190
203
|
const repairable = wireName !== undefined && repairNames.has(wireName);
|
|
191
204
|
if (upstreamItemId && repairable) repairItemNames.set(upstreamItemId, parsed.item.name);
|
|
192
|
-
const restored = repairable
|
|
193
|
-
? restoreRoutedCustomCalls(parsed, names, repairNames)
|
|
205
|
+
const restored = repairable || aliased
|
|
206
|
+
? restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames)
|
|
194
207
|
: { value: parsed, changed: false };
|
|
208
|
+
if (type === "response.output_item.done" && upstreamItemId) releaseCall(upstreamItemId);
|
|
195
209
|
return restored.changed
|
|
196
210
|
? [replaceSseDataPayload(block, JSON.stringify(restored.value))]
|
|
197
211
|
: [block];
|
|
@@ -203,12 +217,14 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
203
217
|
&& typeof parsed.item.name === "string"
|
|
204
218
|
) {
|
|
205
219
|
const upstreamItemId = typeof parsed.item.id === "string" ? parsed.item.id : undefined;
|
|
220
|
+
const targetName = routedCustomToolTargetName(parsed.item, names, declaredNames);
|
|
221
|
+
const routed = targetName !== undefined;
|
|
206
222
|
const wireName = routedCustomToolWireName(parsed.item);
|
|
207
|
-
const routed = wireName !== undefined && names.has(wireName);
|
|
208
223
|
if (upstreamItemId) {
|
|
209
224
|
if (routed) {
|
|
210
225
|
itemNames.set(upstreamItemId, {
|
|
211
226
|
name: parsed.item.name,
|
|
227
|
+
aliased: targetName !== wireName,
|
|
212
228
|
...(typeof parsed.item.namespace === "string" ? { namespace: parsed.item.namespace } : {}),
|
|
213
229
|
});
|
|
214
230
|
ordinaryItemIds.delete(upstreamItemId);
|
|
@@ -227,7 +243,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
227
243
|
if (upstreamItemId && pending.length > 0 && !openCalls.has(upstreamItemId)) {
|
|
228
244
|
openCalls.set(upstreamItemId, { argumentsText: "", emittedInput: "", retainedBytes: 0 });
|
|
229
245
|
}
|
|
230
|
-
const restored = restoreRoutedCustomCalls(parsed, names, repairNames);
|
|
246
|
+
const restored = restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames);
|
|
231
247
|
const restoredBlock = restored.changed
|
|
232
248
|
? replaceSseDataPayload(block, JSON.stringify(restored.value))
|
|
233
249
|
: block;
|
|
@@ -239,6 +255,33 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
239
255
|
}
|
|
240
256
|
|
|
241
257
|
const upstreamItemId = typeof parsed.item_id === "string" ? parsed.item_id : undefined;
|
|
258
|
+
if (
|
|
259
|
+
type === "response.custom_tool_call_input.delta"
|
|
260
|
+
&& upstreamItemId
|
|
261
|
+
&& customAliasItemNames.has(upstreamItemId)
|
|
262
|
+
) {
|
|
263
|
+
const open = openCalls.get(upstreamItemId) ?? { argumentsText: "", emittedInput: "", retainedBytes: 0 };
|
|
264
|
+
const delta = typeof parsed.delta === "string" ? parsed.delta : "";
|
|
265
|
+
const deltaBytes = Buffer.byteLength(delta, "utf8");
|
|
266
|
+
if (deltaBytes > 0) budget?.chargeRetained(deltaBytes, { kind: "retained_collectors" });
|
|
267
|
+
open.argumentsText += delta;
|
|
268
|
+
open.retainedBytes += deltaBytes;
|
|
269
|
+
openCalls.set(upstreamItemId, open);
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
if (
|
|
273
|
+
type === "response.custom_tool_call_input.done"
|
|
274
|
+
&& upstreamItemId
|
|
275
|
+
&& customAliasItemNames.has(upstreamItemId)
|
|
276
|
+
) {
|
|
277
|
+
const source = typeof parsed.input === "string"
|
|
278
|
+
? parsed.input
|
|
279
|
+
: openCalls.get(upstreamItemId)?.argumentsText ?? "";
|
|
280
|
+
return [replaceSseDataPayload(block, JSON.stringify({
|
|
281
|
+
...parsed,
|
|
282
|
+
input: compileCodeModeHelperInput(source, customAliasItemNames.get(upstreamItemId)!),
|
|
283
|
+
}))];
|
|
284
|
+
}
|
|
242
285
|
if (
|
|
243
286
|
type === "response.custom_tool_call_input.done"
|
|
244
287
|
&& upstreamItemId
|
|
@@ -301,12 +344,14 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
301
344
|
...rest,
|
|
302
345
|
type: nextType,
|
|
303
346
|
item_id: customToolItemId(upstreamItemId),
|
|
304
|
-
input:
|
|
347
|
+
input: itemName?.aliased
|
|
348
|
+
? compileCodeModeHelperInput(source, itemName.name)
|
|
349
|
+
: unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
|
|
305
350
|
};
|
|
306
351
|
return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))];
|
|
307
352
|
}
|
|
308
353
|
|
|
309
|
-
const restored = restoreRoutedCustomCalls(parsed, names, repairNames);
|
|
354
|
+
const restored = restoreRoutedCustomCalls(parsed, names, repairNames, declaredNames);
|
|
310
355
|
const terminal = type === "response.completed" || type === "response.failed" || type === "response.incomplete";
|
|
311
356
|
if (terminal) releaseAll();
|
|
312
357
|
return restored.changed
|
|
@@ -22,6 +22,22 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
|
22
22
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
function isUnframedTerminalLikeSuffix(block: string): boolean {
|
|
26
|
+
const payload = sseDataPayload(block);
|
|
27
|
+
if (payload === "[DONE]") return true;
|
|
28
|
+
if (!payload) return false;
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(payload);
|
|
31
|
+
if (!isPlainRecord(parsed)) return false;
|
|
32
|
+
return parsed.type === "response.completed"
|
|
33
|
+
|| parsed.type === "response.failed"
|
|
34
|
+
|| parsed.type === "response.incomplete"
|
|
35
|
+
|| parsed.type === "error";
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
25
41
|
function outputIndex(value: unknown): number | null {
|
|
26
42
|
return Number.isInteger(value) && (value as number) >= 0 ? value as number : null;
|
|
27
43
|
}
|
|
@@ -292,11 +308,16 @@ export function relayResponsesSseWithTerminalRepair(
|
|
|
292
308
|
if (done) {
|
|
293
309
|
appendBuffer(decoder.decode());
|
|
294
310
|
if (buffer.length > 0) {
|
|
295
|
-
// A delimiter-less suffix is not a complete SSE event. Preserve
|
|
296
|
-
//
|
|
297
|
-
//
|
|
311
|
+
// A delimiter-less suffix is not a complete SSE event. Preserve an
|
|
312
|
+
// ordinary suffix for passthrough compatibility, but never promote
|
|
313
|
+
// a terminal-like suffix by adding the delimiter it did not receive
|
|
314
|
+
// upstream. The latter must stay tainted and fail closed through the
|
|
315
|
+
// synthetic incomplete terminal below.
|
|
298
316
|
tainted = true;
|
|
299
|
-
|
|
317
|
+
if (!isUnframedTerminalLikeSuffix(buffer)) {
|
|
318
|
+
controller.enqueue(encoder.encode(buffer));
|
|
319
|
+
controller.enqueue(encoder.encode(buffer.includes("\r\n") ? "\r\n\r\n" : "\n\n"));
|
|
320
|
+
}
|
|
300
321
|
}
|
|
301
322
|
if (!realTerminalSeen) {
|
|
302
323
|
emitSynthetic(completeCandidate() ? "completed" : "incomplete", controller);
|