@bitkyc08/opencodex 2.33.0 → 2.34.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-C4TMRloX.js +112 -0
- package/gui/dist/assets/index-DQ-Ie18T.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 +40 -3
- 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 +14 -0
- package/src/adapters/cursor/message-mapper.ts +4 -1
- package/src/adapters/cursor/native-exec-desktop.ts +23 -0
- package/src/adapters/cursor/native-exec-fs.ts +2 -2
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +5 -2
- package/src/adapters/cursor/native-exec.ts +20 -0
- package/src/adapters/cursor/protobuf-request.ts +68 -14
- package/src/adapters/cursor/request-builder.ts +29 -6
- package/src/adapters/cursor/tool-definitions.ts +11 -2
- package/src/adapters/cursor/tool-result-normalize.ts +32 -1
- package/src/adapters/cursor/types.ts +20 -0
- package/src/adapters/cursor.ts +189 -37
- package/src/adapters/google-antigravity-replay.ts +102 -23
- package/src/adapters/google-errors.ts +44 -12
- package/src/adapters/google.ts +115 -16
- 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 +334 -24
- package/src/adapters/tool-catalog-nudge.ts +2 -2
- package/src/adapters/xai-tool-schema.ts +436 -0
- package/src/bridge.ts +67 -24
- package/src/chat/inbound.ts +14 -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 +32 -4
- package/src/cli/account-extended.ts +1 -1
- 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/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/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 +46 -8
- package/src/codex/auth-context.ts +21 -18
- package/src/codex/catalog/aggregation.ts +6 -0
- 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 +91 -10
- package/src/codex/routing.ts +9 -0
- package/src/codex/subagent-model-fallback.ts +104 -25
- 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/rebase-provenance.ts +68 -0
- package/src/config.ts +107 -9
- package/src/generated/compatibility-version.json +191 -139
- 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/generic-account-failover.ts +231 -0
- package/src/oauth/index.ts +57 -5
- package/src/oauth/key-providers.ts +10 -1
- package/src/oauth/kiro.ts +45 -0
- package/src/oauth/types.ts +15 -0
- 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 +28 -0
- 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 +65 -7
- package/src/providers/registry.ts +160 -68
- 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 +40 -2
- package/src/routing/analytics.ts +1 -0
- package/src/routing/quota.ts +10 -0
- package/src/server/auth-cors.ts +4 -0
- package/src/server/chat-completions.ts +24 -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/index.ts +77 -11
- 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/model-routes.ts +266 -0
- package/src/server/management/provider-routes.ts +6 -0
- package/src/server/management/routing-profile-routes.ts +2 -2
- package/src/server/management-api.ts +2 -0
- package/src/server/relay-eager.ts +147 -20
- package/src/server/relay.ts +251 -19
- package/src/server/request-log-conversation.ts +21 -0
- package/src/server/request-log.ts +41 -15
- package/src/server/responses/collaboration.ts +37 -3
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/core.ts +494 -114
- package/src/server/responses/empty-completion-guard.ts +35 -0
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/input-admission.ts +3 -1
- package/src/server/responses/passthrough-error.ts +33 -9
- package/src/server/responses/responses-field-backfill.ts +105 -13
- 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/types/config.ts +53 -0
- package/src/types/provider.ts +64 -0
- package/src/types/request.ts +11 -3
- package/src/types/tools.ts +7 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +43 -24
- package/src/usage/log.ts +3 -0
- package/src/web-search/exa-executor.ts +40 -9
- package/src/web-search/loop.ts +5 -4
- package/gui/dist/assets/index-23-Lf7jR.js +0 -102
- package/gui/dist/assets/index-DxJMDyOr.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
|
|
@@ -69,7 +69,7 @@ export function providerFetch(
|
|
|
69
69
|
};
|
|
70
70
|
const httpFetch = Object.assign(
|
|
71
71
|
(input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) =>
|
|
72
|
-
base(input, withUpstreamHttpVersion(input, init, provider)),
|
|
72
|
+
base(input, { ...withUpstreamHttpVersion(input, init, provider), timeout: 0 }),
|
|
73
73
|
{ preconnect },
|
|
74
74
|
) as typeof globalThis.fetch;
|
|
75
75
|
// ChatGPT Codex backend: streaming turns ride the responses_websockets
|
|
@@ -139,6 +139,7 @@ export async function fetchWithHeaderTimeout(
|
|
|
139
139
|
// indistinguishable from a pre-connection failure (#914).
|
|
140
140
|
...(manualRedirect ? { redirect: "manual" as const } : {}),
|
|
141
141
|
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
142
|
+
timeout: 0,
|
|
142
143
|
});
|
|
143
144
|
} finally {
|
|
144
145
|
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, {
|
|
@@ -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
|
}
|
|
@@ -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);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
|
|
2
|
+
|
|
1
3
|
export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
2
4
|
|
|
3
5
|
const LF_LF = Uint8Array.of(10, 10);
|
|
@@ -106,10 +108,35 @@ function isResponsesTerminalFrame(block: Uint8Array): boolean {
|
|
|
106
108
|
const payload = data.join("\n");
|
|
107
109
|
if (payload === "[DONE]") return false;
|
|
108
110
|
try {
|
|
109
|
-
const parsed = JSON.parse(payload) as {
|
|
110
|
-
|
|
111
|
+
const parsed = JSON.parse(payload) as {
|
|
112
|
+
type?: unknown;
|
|
113
|
+
code?: unknown;
|
|
114
|
+
message?: unknown;
|
|
115
|
+
error?: unknown;
|
|
116
|
+
last_error?: unknown;
|
|
117
|
+
response?: { error?: unknown; incomplete_details?: unknown };
|
|
118
|
+
};
|
|
119
|
+
if (parsed.type === "response.completed"
|
|
111
120
|
|| parsed.type === "response.failed"
|
|
112
|
-
|| parsed.type === "response.incomplete";
|
|
121
|
+
|| parsed.type === "response.incomplete") return true;
|
|
122
|
+
if (parsed.type !== "error") return false;
|
|
123
|
+
// A top-level error is terminal for this boundary only when it carries the
|
|
124
|
+
// same high-confidence cyber-policy evidence used by relay.ts. Ordinary
|
|
125
|
+
// upstream errors remain transport failures and still become a bounded 502.
|
|
126
|
+
const candidates = [
|
|
127
|
+
parsed,
|
|
128
|
+
parsed.error,
|
|
129
|
+
parsed.last_error,
|
|
130
|
+
parsed.response?.error,
|
|
131
|
+
parsed.response?.incomplete_details,
|
|
132
|
+
];
|
|
133
|
+
for (const candidate of candidates) {
|
|
134
|
+
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue;
|
|
135
|
+
const record = candidate as { code?: unknown; message?: unknown };
|
|
136
|
+
if (isCyberPolicyCode(typeof record.code === "string" ? record.code : undefined)) return true;
|
|
137
|
+
if (typeof record.message === "string" && isCyberPolicyMessage(record.message)) return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
113
140
|
} catch {
|
|
114
141
|
return false;
|
|
115
142
|
}
|
|
@@ -289,4 +316,4 @@ export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
|
289
316
|
offset += part.byteLength;
|
|
290
317
|
}
|
|
291
318
|
return joined;
|
|
292
|
-
}
|
|
319
|
+
}
|
package/src/server/ws-bridge.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface WsData {
|
|
|
34
34
|
authContext?: CodexAuthContext; // last resolved account decision for observability/registry cleanup
|
|
35
35
|
cancel?: () => void; // cancels the in-flight stream reader/fetch
|
|
36
36
|
turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
|
|
37
|
+
/** Fixed-size logical session lane derived at the HTTP upgrade boundary. */
|
|
38
|
+
sessionLaneId?: string;
|
|
37
39
|
/** Discriminator: Responses reframing vs transparent live/realtime sideband relay. */
|
|
38
40
|
kind?: "responses" | "live-sideband";
|
|
39
41
|
liveUpstream?: WebSocket;
|
|
@@ -60,10 +62,20 @@ export interface WsData {
|
|
|
60
62
|
* A test that only asserts "the socket opened" would still pass if the admission
|
|
61
63
|
* were dropped from the payload, so the payload itself is what gets asserted.
|
|
62
64
|
*/
|
|
63
|
-
export function buildResponsesWsData(
|
|
65
|
+
export function buildResponsesWsData(
|
|
66
|
+
headers: Headers,
|
|
67
|
+
admission: DataPlaneAdmission,
|
|
68
|
+
admissionLease?: AdmissionReservation<ServerWebSocket<WsData>>,
|
|
69
|
+
sessionLaneId?: string,
|
|
70
|
+
): WsData {
|
|
64
71
|
// Auth is handshake-time only on this path: the per-frame contexts have no
|
|
65
72
|
// request headers left to re-resolve from, so the decision rides along here.
|
|
66
|
-
return {
|
|
73
|
+
return {
|
|
74
|
+
headers,
|
|
75
|
+
admission,
|
|
76
|
+
...(admissionLease ? { admissionLease } : {}),
|
|
77
|
+
...(sessionLaneId ? { sessionLaneId } : {}),
|
|
78
|
+
};
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
export class WsSendDroppedError extends Error {
|