@bitkyc08/opencodex 2.23.0-preview.20260816 → 2.24.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/gui/dist/assets/index-CFqJKF2L.js +102 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +39 -7
- package/src/adapters/cursor/tool-definitions.ts +48 -0
- package/src/adapters/google.ts +18 -12
- package/src/adapters/openai-chat.ts +106 -13
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +3 -0
- package/src/bridge.ts +16 -5
- package/src/chat/inbound.ts +5 -11
- package/src/claude/context-windows.ts +20 -4
- package/src/claude/desktop-3p.ts +11 -6
- package/src/claude/inbound.ts +39 -1
- package/src/claude/model-info.ts +28 -8
- package/src/cli/account-api.ts +5 -1
- package/src/cli/claude-desktop.ts +3 -0
- package/src/cli/config-command.ts +37 -14
- package/src/codex/app-server-restart-service.ts +1 -1
- package/src/codex/auth-api.ts +5 -0
- package/src/codex/auth-context.ts +43 -2
- package/src/codex/catalog/metadata.ts +135 -12
- package/src/codex/catalog/native-models.ts +32 -2
- package/src/codex/catalog/parsing.ts +61 -13
- package/src/codex/catalog/provider-fetch.ts +37 -7
- package/src/codex/catalog/sync.ts +49 -25
- package/src/codex/catalog-refresh-status.ts +21 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +23 -2
- package/src/codex/desired-state.ts +1 -1
- package/src/codex/inject.ts +38 -7
- package/src/codex/injected-marker.ts +28 -0
- package/src/codex/journal.ts +40 -1
- package/src/codex/management-convergence.ts +55 -2
- package/src/codex/quota-rejection.ts +61 -1
- package/src/codex/quota.ts +60 -6
- package/src/codex/routing.ts +30 -3
- package/src/combos/failover.ts +20 -0
- package/src/config.ts +271 -4
- package/src/generated/compatibility-version.json +90 -78
- package/src/grok/sync.ts +3 -1
- package/src/lab/artifacts/sanitize.ts +1 -1
- package/src/lab/live/manifest.ts +1 -1
- package/src/lib/codex-restart-contract.ts +1 -1
- package/src/lib/config-ownership.ts +1 -0
- package/src/lib/errors.ts +9 -0
- package/src/lib/lab-activation.ts +1 -1
- package/src/lib/optional-shutdown-hooks.ts +1 -1
- package/src/lib/pinned-http.ts +7 -2
- package/src/lib/windows-elevation.ts +3 -3
- package/src/providers/quota.ts +10 -4
- package/src/providers/registry.ts +2 -2
- package/src/responses/parser.ts +42 -7
- package/src/responses/provider-opaque-metadata.ts +1 -1
- package/src/responses/thought-signature-replay.ts +261 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/provider-slot.ts +1 -1
- package/src/routing/evaluator.ts +12 -2
- package/src/routing/health.ts +16 -5
- package/src/routing/history/schema.ts +1 -1
- package/src/routing/trace.ts +1 -1
- package/src/server/auth-cors.ts +96 -21
- package/src/server/chat-completions.ts +6 -2
- package/src/server/chat-native.ts +32 -6
- package/src/server/index.ts +5 -3
- package/src/server/management/agent-settings-routes.ts +26 -4
- package/src/server/management/config-routes.ts +79 -2
- package/src/server/management/context.ts +1 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/provider-routes.ts +19 -0
- package/src/server/management/shared.ts +3 -3
- package/src/server/management-api.ts +13 -6
- package/src/server/passive-route-linker.ts +1 -1
- package/src/server/relay.ts +16 -0
- package/src/server/responses/compact.ts +10 -3
- package/src/server/responses/core.ts +160 -33
- package/src/server/responses/fetch-helpers.ts +34 -2
- package/src/server/responses/input-admission.ts +17 -9
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/system-env.ts +4 -2
- package/src/service.ts +22 -7
- package/src/types.ts +35 -1
- package/gui/dist/assets/index-Ch-YtWdA.js +0 -102
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { loadConfig, readRuntimePort, saveConfigPreservingClaudeCode } from "../../config";
|
|
21
21
|
import { desktopVisibleNativeSlugs, filterCatalogVisibleModels, nativeOpenAiContextWindow, visibleNativeSlugs } from "../../codex/catalog";
|
|
22
|
+
import { providerContextCap } from "../../providers/context-cap";
|
|
23
|
+
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
22
24
|
import { inspectDesktop3pConfigLibrary, removeDesktop3pStandardPivot, writeDesktop3pConfig } from "../../claude/desktop-3p";
|
|
23
25
|
import { injectGrokConfig, stripGrokConfig, type GrokInjectModel } from "../../grok/inject";
|
|
24
26
|
import { inspectGrokConfig } from "../../grok/inspect";
|
|
@@ -506,7 +508,7 @@ async function handleGrokToggle(ctx: ManagementContext): Promise<Response> {
|
|
|
506
508
|
// Native slugs carry their context window: without it Grok falls back
|
|
507
509
|
// to its own 200k default and understates a 372k model.
|
|
508
510
|
...visibleNativeSlugs(config).map(id => {
|
|
509
|
-
const contextWindow = nativeOpenAiContextWindow(id);
|
|
511
|
+
const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID));
|
|
510
512
|
return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
511
513
|
}),
|
|
512
514
|
...routed.map(m => ({
|
|
@@ -661,6 +663,7 @@ async function handleClaudeDesktopToggle(ctx: ManagementContext): Promise<Respon
|
|
|
661
663
|
latest.apiKeys?.[0]?.key,
|
|
662
664
|
"static",
|
|
663
665
|
latest.claudeCode?.desktopProfile,
|
|
666
|
+
providerContextCap(latest, OPENAI_CODEX_PROVIDER_ID),
|
|
664
667
|
);
|
|
665
668
|
if (!result.written) return postCommitRefusal(500, "claude-desktop", "write_failed", "Claude Desktop apply failed.", { desiredEnabled: latestDesiredEnabled });
|
|
666
669
|
return jsonResponse({
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
requestPacingConfigError,
|
|
18
18
|
readConfigAdmissionSnapshot,
|
|
19
19
|
saveConfigPreservingClaudeCode,
|
|
20
|
+
upstreamHttpVersionConfigError,
|
|
20
21
|
withConfigMutationLockSync,
|
|
21
22
|
} from "../../config";
|
|
22
23
|
import {
|
|
@@ -194,6 +195,19 @@ function applyProviderPatchFields(
|
|
|
194
195
|
}
|
|
195
196
|
touched = true;
|
|
196
197
|
}
|
|
198
|
+
if (Object.hasOwn(rawBody, "upstreamHttpVersion")) {
|
|
199
|
+
const value = rawBody.upstreamHttpVersion;
|
|
200
|
+
if (value === null || value === "") {
|
|
201
|
+
delete next.upstreamHttpVersion;
|
|
202
|
+
} else {
|
|
203
|
+
const versionError = upstreamHttpVersionConfigError(value);
|
|
204
|
+
if (versionError) return { error: versionError };
|
|
205
|
+
// `upstreamHttpVersionConfigError` is the shared write boundary; the assertion is
|
|
206
|
+
// explicit because the incoming value is an unknown JSON scalar.
|
|
207
|
+
next.upstreamHttpVersion = value as OcxProviderConfig["upstreamHttpVersion"];
|
|
208
|
+
}
|
|
209
|
+
touched = true;
|
|
210
|
+
}
|
|
197
211
|
// The Models page edits the catalog hints in place; keep them on the existing
|
|
198
212
|
// provider mutation path so validation, cache invalidation, and convergence stay unified (#1073).
|
|
199
213
|
if (Object.hasOwn(rawBody, "contextWindow")) {
|
|
@@ -374,6 +388,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
374
388
|
modelContextWindows: p.modelContextWindows,
|
|
375
389
|
modelSupportsServiceTier: p.modelSupportsServiceTier,
|
|
376
390
|
noStructuredOutputModels: p.noStructuredOutputModels,
|
|
391
|
+
upstreamHttpVersion: p.upstreamHttpVersion,
|
|
377
392
|
authMode: p.authMode,
|
|
378
393
|
apiKeyTransport: p.apiKeyTransport,
|
|
379
394
|
disabled: p.disabled === true,
|
|
@@ -466,6 +481,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
466
481
|
const serviceTierError = providerServiceTierConfigError(name, body.provider);
|
|
467
482
|
if (serviceTierError) return jsonResponse({ error: serviceTierError }, 400);
|
|
468
483
|
const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider as OcxProviderConfig) : undefined;
|
|
484
|
+
// PATCH already clears on null; POST persisted the body as submitted, so a `null` here
|
|
485
|
+
// reached disk and the next loadConfig() refused it. Canonicalize to absent, which is what
|
|
486
|
+
// "clear" means everywhere else.
|
|
487
|
+
if (prov && prov.upstreamHttpVersion === null) delete prov.upstreamHttpVersion;
|
|
469
488
|
if (!name || !prov?.adapter || !prov?.baseUrl) {
|
|
470
489
|
return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
|
|
471
490
|
}
|
|
@@ -28,7 +28,7 @@ import { deriveProviderPresets } from "../../providers/derive";
|
|
|
28
28
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
29
29
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
30
30
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
|
|
31
|
-
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
31
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
32
32
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
33
33
|
import { primeCodexPoolQuotas } from "../../codex/auth-api";
|
|
34
34
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
|
|
@@ -196,7 +196,7 @@ export async function fetchGrokCandidateModels(config: OcxConfig): Promise<GrokC
|
|
|
196
196
|
const routed = filterCatalogVisibleModels(await fetchAllModels(config), config);
|
|
197
197
|
return [
|
|
198
198
|
...visibleNativeSlugs(config).map(id => {
|
|
199
|
-
const contextWindow = nativeOpenAiContextWindow(id);
|
|
199
|
+
const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID));
|
|
200
200
|
return { id, native: true, ...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
201
201
|
}),
|
|
202
202
|
...routed.map(m => ({
|
|
@@ -229,7 +229,7 @@ export async function buildClaudeDesktopState(config: OcxConfig, stored?: OcxCla
|
|
|
229
229
|
// Native rows carry their real context window from the same accessor the Grok sync
|
|
230
230
|
// uses — otherwise Sol's 372k and gpt-5.5's 272k render as blank on Desktop.
|
|
231
231
|
...desktopVisibleNativeSlugs(config).map(id => {
|
|
232
|
-
const contextWindow = nativeOpenAiContextWindow(id);
|
|
232
|
+
const contextWindow = nativeOpenAiContextWindow(id, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID));
|
|
233
233
|
return { route: `native/${id}`, label: `${id} (native)`,
|
|
234
234
|
...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
235
235
|
}),
|
|
@@ -28,7 +28,7 @@ import { deriveProviderPresets } from "../providers/derive";
|
|
|
28
28
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
29
29
|
import { routedSlug, slugEquals } from "../providers/slug-codec";
|
|
30
30
|
import { clearProviderQuotaCache, fetchProviderQuotaReports } from "../providers/quota";
|
|
31
|
-
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers";
|
|
31
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
32
32
|
import { clearThreadAccountMap } from "../codex/routing";
|
|
33
33
|
import { primeCodexPoolQuotas } from "../codex/auth-api";
|
|
34
34
|
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../providers/context-cap";
|
|
@@ -109,7 +109,7 @@ function pathInManagementNamespace(pathname: string, prefix: string): boolean {
|
|
|
109
109
|
* keeps `management-api.ts` on the same footing as the three protected core files.
|
|
110
110
|
*
|
|
111
111
|
* Cherry-picked from @Wibias's PR #1676, which solved this before the boundary work
|
|
112
|
-
* reached it. See devlog/
|
|
112
|
+
* reached it. See devlog/_fin/260814_lab_core_decoupling/.
|
|
113
113
|
*/
|
|
114
114
|
async function handleRoutingProfileRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
115
115
|
if (!pathInManagementNamespace(ctx.url.pathname, "/api/routing-profiles")) return null;
|
|
@@ -174,13 +174,20 @@ export async function handleManagementAPI(
|
|
|
174
174
|
throw new TypeError("Catalog convergence returned an invalid outcome.");
|
|
175
175
|
}
|
|
176
176
|
return catalogRefresh;
|
|
177
|
-
} catch {
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// #1784: this used to manufacture `reason: "disk"` for every escaping error, so a
|
|
179
|
+
// programming fault and a full filesystem were indistinguishable and both reported
|
|
180
|
+
// non-retryable. Classify honestly and keep the cause allowlisted.
|
|
181
|
+
const invalidRequest = error instanceof TypeError
|
|
182
|
+
|| error instanceof RangeError
|
|
183
|
+
|| error instanceof SyntaxError;
|
|
178
184
|
return {
|
|
179
185
|
status: "failed",
|
|
180
|
-
reason: "
|
|
186
|
+
reason: invalidRequest ? "request-invalid" : "internal",
|
|
181
187
|
phase: convergenceInvoked ? "commit" : "gather",
|
|
182
188
|
retryable: false,
|
|
183
189
|
partialWrite: convergenceInvoked,
|
|
190
|
+
cause: { kind: invalidRequest ? "invalid-request" : "unknown" },
|
|
184
191
|
};
|
|
185
192
|
}
|
|
186
193
|
}
|
|
@@ -198,7 +205,7 @@ export async function handleManagementAPI(
|
|
|
198
205
|
import("../claude/context-windows"),
|
|
199
206
|
import("../codex/catalog"),
|
|
200
207
|
]);
|
|
201
|
-
injectClaudeAgentDefs(config, buildClaudeContextWindows([...visibleNativeSlugs(config)], models));
|
|
208
|
+
injectClaudeAgentDefs(config, buildClaudeContextWindows([...visibleNativeSlugs(config)], models, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID)));
|
|
202
209
|
} catch {
|
|
203
210
|
// Keep routes available through a provider-discovery blip. A later
|
|
204
211
|
// launch-time sync restores any context markers missing from this pass.
|
|
@@ -302,4 +309,4 @@ export async function handleManagementAPI(
|
|
|
302
309
|
}
|
|
303
310
|
|
|
304
311
|
|
|
305
|
-
export { buildClaudeDesktopState, fetchAllModels } from "./management/shared";
|
|
312
|
+
export { buildClaudeDesktopState, fetchAllModels } from "./management/shared";
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* call site so the guarantee belongs to the mechanism instead of being restated by every
|
|
13
13
|
* caller.
|
|
14
14
|
*
|
|
15
|
-
* See devlog/
|
|
15
|
+
* See devlog/_fin/260814_lab_core_decoupling/020_request_path_gate.md
|
|
16
16
|
*/
|
|
17
17
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
18
18
|
import type { InboundWire } from "../providers/registry";
|
package/src/server/relay.ts
CHANGED
|
@@ -603,6 +603,13 @@ export type SseInspectorHandlers = {
|
|
|
603
603
|
onTerminal?: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void;
|
|
604
604
|
logCtx?: RequestLogContext;
|
|
605
605
|
onCompletedResponse?: (response: { id?: unknown; output?: unknown; status?: unknown }) => void;
|
|
606
|
+
/**
|
|
607
|
+
* Every parsed SSE payload, delivered BEFORE any onCompletedResponse derived from that same
|
|
608
|
+
* payload. A caller that must decide on the whole turn -- not just its terminal snapshot --
|
|
609
|
+
* needs to see the incremental events, because a stream can announce an item and then close
|
|
610
|
+
* with an empty `output`.
|
|
611
|
+
*/
|
|
612
|
+
onParsedPayload?: (payload: unknown) => void;
|
|
606
613
|
onFirstOutput?: () => void;
|
|
607
614
|
/**
|
|
608
615
|
* Provider-scoped compatibility: persist the completed snapshot under the
|
|
@@ -786,6 +793,11 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
|
|
|
786
793
|
if (!reported && handlers.logCtx) {
|
|
787
794
|
inspectResponseLogSsePayloadParsed(handlers.logCtx, payload, parsed);
|
|
788
795
|
}
|
|
796
|
+
// Before any terminal handling: a consumer deciding on the whole turn must observe this
|
|
797
|
+
// payload even when the terminal snapshot that follows no longer mentions it.
|
|
798
|
+
if (handlers.onParsedPayload && parsed !== undefined) {
|
|
799
|
+
try { handlers.onParsedPayload(parsed); } catch { /* inspection must never throw into the pump */ }
|
|
800
|
+
}
|
|
789
801
|
reportFirstOutput.parsed(parsed);
|
|
790
802
|
const status = terminalStatusFromParsed(parsed);
|
|
791
803
|
if (status) sawTerminal = true;
|
|
@@ -953,6 +965,8 @@ export type InspectionConsumerOptions = {
|
|
|
953
965
|
now?: () => number;
|
|
954
966
|
/** Forward provider-scoped response-id pinning to the owned inspector. */
|
|
955
967
|
pinCompletedResponseIdToFirstSeen?: boolean;
|
|
968
|
+
/** Observe every parsed SSE payload on the inspection side; see SseInspectorHandlers. */
|
|
969
|
+
onParsedPayload?: (payload: unknown) => void;
|
|
956
970
|
/** Test seam for proving both public consumers dispose their owned inspector. */
|
|
957
971
|
inspectorFactory?: (handlers: SseInspectorHandlers) => SseInspector;
|
|
958
972
|
};
|
|
@@ -1108,6 +1122,7 @@ export function consumeForInspection(
|
|
|
1108
1122
|
onTerminal,
|
|
1109
1123
|
logCtx,
|
|
1110
1124
|
onCompletedResponse,
|
|
1125
|
+
onParsedPayload: options?.onParsedPayload,
|
|
1111
1126
|
onFirstOutput,
|
|
1112
1127
|
pinCompletedResponseIdToFirstSeen: options?.pinCompletedResponseIdToFirstSeen,
|
|
1113
1128
|
});
|
|
@@ -1158,6 +1173,7 @@ export function consumeForResponseLogMetadata(
|
|
|
1158
1173
|
const inspector = (options?.inspectorFactory ?? createSseInspector)({
|
|
1159
1174
|
logCtx,
|
|
1160
1175
|
onCompletedResponse,
|
|
1176
|
+
onParsedPayload: options?.onParsedPayload,
|
|
1161
1177
|
onFirstOutput,
|
|
1162
1178
|
pinCompletedResponseIdToFirstSeen: options?.pinCompletedResponseIdToFirstSeen,
|
|
1163
1179
|
});
|
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
CodexPoolAuthenticationError,
|
|
52
52
|
CodexThreadAffinityExpiredError,
|
|
53
53
|
headersForCodexAuthContext,
|
|
54
|
+
materializeCodexUpstreamAuth,
|
|
55
|
+
CodexMainSubstitutionUnavailableError,
|
|
54
56
|
isCodexAuthContextUsable,
|
|
55
57
|
resolveCodexAuthContext,
|
|
56
58
|
codexProbeLeaseId,
|
|
@@ -81,6 +83,7 @@ import {
|
|
|
81
83
|
type UpstreamHostAdmissionLease,
|
|
82
84
|
} from "../../codex/upstream-host-health";
|
|
83
85
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
86
|
+
import type { DataPlaneAdmission } from "../auth-cors";
|
|
84
87
|
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
85
88
|
import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider, supportsNativeResponsesCompactEndpoint } from "../../providers/openai-tiers";
|
|
86
89
|
import { slugsEquivalent } from "../../providers/slug-codec";
|
|
@@ -269,6 +272,7 @@ export async function handleResponsesCompact(
|
|
|
269
272
|
config: OcxConfig,
|
|
270
273
|
logCtx: RequestLogContext,
|
|
271
274
|
turnAdmissionLease?: AdmissionLease,
|
|
275
|
+
admission?: DataPlaneAdmission,
|
|
272
276
|
): Promise<Response> {
|
|
273
277
|
let body: unknown;
|
|
274
278
|
try {
|
|
@@ -316,7 +320,10 @@ export async function handleResponsesCompact(
|
|
|
316
320
|
logCtx.resolvedModel = route.modelId;
|
|
317
321
|
}
|
|
318
322
|
|
|
319
|
-
|
|
323
|
+
// #1686: a bearer-presented admission secret is one of ours, so the stored main credential
|
|
324
|
+
// is substituted below instead of the caller bearer being forwarded.
|
|
325
|
+
const substituteMainCredential = admission?.source === "bearer";
|
|
326
|
+
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
|
|
320
327
|
try { validateForwardAdmissionCredential(req.headers, config); }
|
|
321
328
|
catch (err) {
|
|
322
329
|
if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
|
|
@@ -364,7 +371,7 @@ export async function handleResponsesCompact(
|
|
|
364
371
|
beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
|
|
365
372
|
});
|
|
366
373
|
logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
|
|
367
|
-
const selected =
|
|
374
|
+
const selected = materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential });
|
|
368
375
|
compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
369
376
|
for (const name of FORWARD_HEADERS) {
|
|
370
377
|
const value = selected.get(name);
|
|
@@ -671,7 +678,7 @@ export async function handleResponsesCompact(
|
|
|
671
678
|
headers: internalHeaders,
|
|
672
679
|
body: JSON.stringify(internalBody),
|
|
673
680
|
});
|
|
674
|
-
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease });
|
|
681
|
+
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
|
|
675
682
|
if (!response.ok) return response;
|
|
676
683
|
let json: { output?: unknown[]; status?: unknown; error?: unknown };
|
|
677
684
|
try {
|
|
@@ -91,6 +91,8 @@ import {
|
|
|
91
91
|
CodexPoolAuthenticationError,
|
|
92
92
|
CodexThreadAffinityExpiredError,
|
|
93
93
|
headersForCodexAuthContext,
|
|
94
|
+
materializeCodexUpstreamAuth,
|
|
95
|
+
CodexMainSubstitutionUnavailableError,
|
|
94
96
|
isCodexAuthContextUsable,
|
|
95
97
|
resolveCodexAuthContext,
|
|
96
98
|
codexProbeLeaseId,
|
|
@@ -114,9 +116,11 @@ import {
|
|
|
114
116
|
prepareSameTarget429Wait,
|
|
115
117
|
} from "../../lib/upstream-retry";
|
|
116
118
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "../auth-cors";
|
|
119
|
+
import type { DataPlaneAdmission } from "../auth-cors";
|
|
117
120
|
import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget";
|
|
118
121
|
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
119
|
-
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
122
|
+
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
123
|
+
import { providerContextCap } from "../../providers/context-cap";
|
|
120
124
|
import { SERVICE_TIER_ADAPTERS, serviceTierSupportForModel } from "../../providers/service-tier";
|
|
121
125
|
import {
|
|
122
126
|
RequestPacingQueueOverloadError,
|
|
@@ -237,6 +241,13 @@ import {
|
|
|
237
241
|
} from "../sse-payload-rewrite";
|
|
238
242
|
import { restoreRoutedCustomCallsInJson } from "../../responses/custom-tool-compat";
|
|
239
243
|
import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-tool-repair";
|
|
244
|
+
import {
|
|
245
|
+
collectDeclaredWireToolNames,
|
|
246
|
+
createUndeclaredToolCallGuardBlockRewrite,
|
|
247
|
+
undeclaredToolCallMessage,
|
|
248
|
+
undeclaredToolCallName,
|
|
249
|
+
undeclaredToolCallNameInResponse,
|
|
250
|
+
} from "../responses-undeclared-tool-guard";
|
|
240
251
|
import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair";
|
|
241
252
|
import { responsesJsonToSseStream } from "../responses-json-events";
|
|
242
253
|
import { guardTerminalEventStream } from "./terminal-guard";
|
|
@@ -457,6 +468,20 @@ type CodexPoolAccountRetryResult =
|
|
|
457
468
|
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
458
469
|
};
|
|
459
470
|
|
|
471
|
+
/**
|
|
472
|
+
* Workspace-denial evidence for a 403, read from the upstream body.
|
|
473
|
+
*
|
|
474
|
+
* #1789: a valid K12 credential gets 403 `codex_workspace_access_denied` on a routed prompt.
|
|
475
|
+
* Without this the account is quarantined for reauthentication, which cannot fix a workspace
|
|
476
|
+
* grant and loops forever. Fails closed: an unreadable body keeps the historical handling.
|
|
477
|
+
*/
|
|
478
|
+
async function codexDenialOutcomeMeta(response: Response): Promise<{ denial?: "workspace" | "entitlement" }> {
|
|
479
|
+
if (response.status !== 403) return {};
|
|
480
|
+
const { classifyCodexPreStreamRejection } = await import("../../codex/quota-rejection");
|
|
481
|
+
const rejection = await classifyCodexPreStreamRejection(response);
|
|
482
|
+
return rejection.denial ? { denial: rejection.denial } : {};
|
|
483
|
+
}
|
|
484
|
+
|
|
460
485
|
function codexQuotaOutcomeMeta(response: Response): {
|
|
461
486
|
retryAfter: string | null;
|
|
462
487
|
resetAt: string[];
|
|
@@ -521,7 +546,7 @@ async function retryCodexPoolOnAlternateAccount(
|
|
|
521
546
|
return { kind: "no-alternate" };
|
|
522
547
|
}
|
|
523
548
|
|
|
524
|
-
const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
|
|
549
|
+
const quotaMeta = { ...codexQuotaOutcomeMeta(firstResponse), ...(await codexDenialOutcomeMeta(firstResponse)) };
|
|
525
550
|
if (outcomeStatus === 429 || outcomeStatus === 402) {
|
|
526
551
|
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
527
552
|
applyAccountQuotaFromUpstreamHeaders(
|
|
@@ -734,6 +759,15 @@ export interface ConsumedComboFailure {
|
|
|
734
759
|
|
|
735
760
|
export interface HandleResponsesOptions {
|
|
736
761
|
turnAdmissionLease?: AdmissionLease;
|
|
762
|
+
/**
|
|
763
|
+
* How the caller proved data-plane admission (#1686).
|
|
764
|
+
*
|
|
765
|
+
* A bearer-presented admission secret is one of OUR OWN secrets, so a Direct turn must
|
|
766
|
+
* SUBSTITUTE the stored main credential rather than forward it. Without this fact at the
|
|
767
|
+
* decision point, Direct cannot tell an admission bearer from the user own ChatGPT bearer,
|
|
768
|
+
* which is why it refused the whole env_key flow instead of serving it.
|
|
769
|
+
*/
|
|
770
|
+
admission?: DataPlaneAdmission;
|
|
737
771
|
/** Called at most once after the complete client body is read and accepted for dispatch. */
|
|
738
772
|
onRequestBodyRead?: () => void;
|
|
739
773
|
forceEmptyResponseId?: boolean;
|
|
@@ -967,7 +1001,14 @@ async function resolveResponsesCodexAuth(
|
|
|
967
1001
|
options: HandleResponsesOptions,
|
|
968
1002
|
): Promise<ResponsesAuthResolution> {
|
|
969
1003
|
try {
|
|
970
|
-
|
|
1004
|
+
// #1686: a caller that proved admission with a BEARER presented one of our own secrets.
|
|
1005
|
+
// Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
|
|
1006
|
+
// Admitting it is only safe because the stored main credential is substituted below, so
|
|
1007
|
+
// the admission secret still never leaves this process.
|
|
1008
|
+
const substituteMainCredential = options.admission?.source === "bearer";
|
|
1009
|
+
if (route.codexAccountMode === "direct" && !substituteMainCredential) {
|
|
1010
|
+
validateForwardAdmissionCredential(req.headers, config);
|
|
1011
|
+
}
|
|
971
1012
|
let authCtx: CodexAuthContext;
|
|
972
1013
|
if (route.codexAccountMode) {
|
|
973
1014
|
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
|
|
@@ -990,7 +1031,7 @@ async function resolveResponsesCodexAuth(
|
|
|
990
1031
|
return {
|
|
991
1032
|
ok: true,
|
|
992
1033
|
authCtx,
|
|
993
|
-
headers:
|
|
1034
|
+
headers: materializeCodexUpstreamAuth(req.headers, authCtx, { substituteMainCredential }),
|
|
994
1035
|
};
|
|
995
1036
|
} catch (err) {
|
|
996
1037
|
if (err instanceof CodexAccountCooldownError) {
|
|
@@ -1024,6 +1065,13 @@ async function resolveResponsesCodexAuth(
|
|
|
1024
1065
|
if (err instanceof ForwardAdmissionCredentialError) {
|
|
1025
1066
|
return { ok: false, response: formatErrorResponse(401, "authentication_error", err.message) };
|
|
1026
1067
|
}
|
|
1068
|
+
if (err instanceof CodexMainSubstitutionUnavailableError) {
|
|
1069
|
+
// Fail BEFORE any upstream I/O. The alternative is forwarding the admission secret.
|
|
1070
|
+
return {
|
|
1071
|
+
ok: false,
|
|
1072
|
+
response: formatErrorResponse(401, "authentication_error", "No usable Codex main credential to serve this request"),
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1027
1075
|
throw err;
|
|
1028
1076
|
}
|
|
1029
1077
|
}
|
|
@@ -1946,11 +1994,16 @@ async function handleResponsesInner(
|
|
|
1946
1994
|
// refusing the turn that shrinks the context would deadlock the client against the very
|
|
1947
1995
|
// limit this gate reports — it would be told to compact and then denied the compaction.
|
|
1948
1996
|
if (parsed._compactionRequest !== true) {
|
|
1949
|
-
const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId);
|
|
1997
|
+
const inputAdmission = checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, providerContextCap(config, OPENAI_CODEX_PROVIDER_ID));
|
|
1950
1998
|
if (!inputAdmission.admitted) {
|
|
1999
|
+
// #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
|
|
2000
|
+
// fallback must be able to skip this candidate and try one whose context window fits,
|
|
2001
|
+
// instead of treating the first incompatible candidate as the end of the chain. The
|
|
2002
|
+
// distinct code is what lets the fallback layer tell the two apart -- an upstream
|
|
2003
|
+
// `context_length_exceeded` still stops, because retrying it elsewhere is guesswork.
|
|
1951
2004
|
return formatErrorResponse(
|
|
1952
2005
|
413,
|
|
1953
|
-
"
|
|
2006
|
+
"input_admission_refused",
|
|
1954
2007
|
`Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
|
|
1955
2008
|
+ `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
|
|
1956
2009
|
+ `model with a larger context window.`,
|
|
@@ -2280,6 +2333,69 @@ async function handleResponsesInner(
|
|
|
2280
2333
|
if (toolBridgeMaps.freeformToolNames.has(name)) routedCustomToolNames.add(name);
|
|
2281
2334
|
}
|
|
2282
2335
|
}
|
|
2336
|
+
// #1700: the bridged paths refuse a call to a tool the request never declared
|
|
2337
|
+
// (`declaredToolNames`, src/bridge.ts). The passthrough had no equivalent, so a routed
|
|
2338
|
+
// provider's top-level `apply_patch` — which under Codex code mode exists only as a nested
|
|
2339
|
+
// `tools.apply_patch(...)` helper inside `exec`, never as a wire tool — reached Codex as a
|
|
2340
|
+
// call it cannot execute, and the turn showed a bare `aborted` with the file untouched.
|
|
2341
|
+
// Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a
|
|
2342
|
+
// routed provider, so it keeps passing through unguarded, as it does for the rewrites above.
|
|
2343
|
+
// The guard needs a catalog to compare against, so it stands down when the request carries
|
|
2344
|
+
// none. That is not the same claim as "no tools means no tool may be called": a passthrough
|
|
2345
|
+
// request can legitimately omit `tools` entirely and still receive a tool call the client
|
|
2346
|
+
// understands — `tests/github-copilot-stream-contract.test.ts` sends `{model, input, stream}`
|
|
2347
|
+
// with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an
|
|
2348
|
+
// empty catalog truncates that turn. An unreadable body lands here too, since it yields no
|
|
2349
|
+
// names either.
|
|
2350
|
+
const outboundRequestBody = (() => {
|
|
2351
|
+
try {
|
|
2352
|
+
const body = JSON.parse(request.body) as unknown;
|
|
2353
|
+
return body && typeof body === "object" && !Array.isArray(body) ? body : undefined;
|
|
2354
|
+
} catch {
|
|
2355
|
+
return undefined;
|
|
2356
|
+
}
|
|
2357
|
+
})();
|
|
2358
|
+
const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody);
|
|
2359
|
+
// Union with the caller's own catalog, because the outbound body is not always a complete
|
|
2360
|
+
// record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a
|
|
2361
|
+
// request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets
|
|
2362
|
+
// the client's name back. Widening only ever makes the guard fire less; a name declared in
|
|
2363
|
+
// neither place — #1700's `apply_patch` — is still refused.
|
|
2364
|
+
for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name);
|
|
2365
|
+
const undeclaredToolGuardActive = declaredWireToolNames.size > 0
|
|
2366
|
+
&& route.provider.authMode !== "forward";
|
|
2367
|
+
// A refused turn must not seed `previous_response_id` replay. The inspection branch reads the
|
|
2368
|
+
// untouched upstream stream, so it can still observe a `response.completed` the client never
|
|
2369
|
+
// received; checking the payload itself rather than a flag shared with the client relay keeps
|
|
2370
|
+
// this free of tee ordering races.
|
|
2371
|
+
//
|
|
2372
|
+
// Checking only the terminal snapshot is not enough. An upstream can announce the undeclared
|
|
2373
|
+
// call in `response.output_item.added`, which trips the client guard, and then close with a
|
|
2374
|
+
// `response.completed` whose `output` is empty. The client gets `response.failed`, the terminal
|
|
2375
|
+
// check sees nothing undeclared, and the refused turn enters continuation state anyway. So the
|
|
2376
|
+
// rejection is sticky for the whole turn, set from every parsed payload on the inspection side.
|
|
2377
|
+
let inspectionSawUndeclaredTool = false;
|
|
2378
|
+
const noteInspectedPayload = (payload: unknown) => {
|
|
2379
|
+
// Gated on the same flag as the guard itself: with no readable catalog (or a forward-auth
|
|
2380
|
+
// provider) every name looks undeclared, and flipping this would stop recording continuation
|
|
2381
|
+
// state for exactly the passthrough traffic the guard deliberately stands down for.
|
|
2382
|
+
if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return;
|
|
2383
|
+
if (undeclaredToolCallName(payload, declaredWireToolNames) !== undefined) {
|
|
2384
|
+
inspectionSawUndeclaredTool = true;
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
const rememberPassthroughResponseChecked = rememberPassthroughResponse
|
|
2388
|
+
? (response: { id?: unknown; output?: unknown; status?: unknown }) => {
|
|
2389
|
+
if (inspectionSawUndeclaredTool) return;
|
|
2390
|
+
if (
|
|
2391
|
+
undeclaredToolGuardActive
|
|
2392
|
+
&& undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined
|
|
2393
|
+
) {
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
rememberPassthroughResponse(response);
|
|
2397
|
+
}
|
|
2398
|
+
: undefined;
|
|
2283
2399
|
recordAdapterReasoning(logCtx, request);
|
|
2284
2400
|
const actualHostKey = upstreamHostHealthKey(
|
|
2285
2401
|
route.providerName,
|
|
@@ -2536,7 +2652,7 @@ async function handleResponsesInner(
|
|
|
2536
2652
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
2537
2653
|
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
2538
2654
|
// Prefer primary when present, fall back to secondary for compatibility.
|
|
2539
|
-
const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
|
|
2655
|
+
const quotaMeta = { ...codexQuotaOutcomeMeta(upstreamResponse), ...(await codexDenialOutcomeMeta(upstreamResponse)) };
|
|
2540
2656
|
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
2541
2657
|
applyAccountQuotaFromUpstreamHeaders(
|
|
2542
2658
|
authCtx.accountId,
|
|
@@ -2667,13 +2783,6 @@ async function handleResponsesInner(
|
|
|
2667
2783
|
// injection at the block level, after payload rewrites. Defaults come
|
|
2668
2784
|
// from the finalized OUTBOUND body — the normalized internal tool shapes
|
|
2669
2785
|
// are not the Responses wire shapes the snapshot must mirror.
|
|
2670
|
-
const snapshotDefaultsRequest = (() => {
|
|
2671
|
-
try {
|
|
2672
|
-
return JSON.parse(request.body) as unknown;
|
|
2673
|
-
} catch {
|
|
2674
|
-
return undefined;
|
|
2675
|
-
}
|
|
2676
|
-
})();
|
|
2677
2786
|
const blockRewrites = [
|
|
2678
2787
|
payloadRewrites.length > 0
|
|
2679
2788
|
? payloadRewriteAsBlockRewrite(composeSsePayloadRewrites(...payloadRewrites))
|
|
@@ -2685,7 +2794,12 @@ async function handleResponsesInner(
|
|
|
2685
2794
|
? createGithubCopilotResponsesBlockRewrite(translatorBudget)
|
|
2686
2795
|
: undefined,
|
|
2687
2796
|
snapshotRepairEnabled
|
|
2688
|
-
? createResponsesSnapshotBlockRewrite(
|
|
2797
|
+
? createResponsesSnapshotBlockRewrite(outboundRequestBody, translatorBudget)
|
|
2798
|
+
: undefined,
|
|
2799
|
+
// Last: every rewrite above can still rename or reshape a call item, so the guard must
|
|
2800
|
+
// compare the names the client will actually receive against the declared catalog.
|
|
2801
|
+
undeclaredToolGuardActive
|
|
2802
|
+
? createUndeclaredToolCallGuardBlockRewrite(declaredWireToolNames)
|
|
2689
2803
|
: undefined,
|
|
2690
2804
|
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
2691
2805
|
const clientBlockRewrite = blockRewrites.length > 0
|
|
@@ -2737,7 +2851,8 @@ async function handleResponsesInner(
|
|
|
2737
2851
|
const inspector = createSseInspector({
|
|
2738
2852
|
onTerminal: reportNativeTerminal,
|
|
2739
2853
|
logCtx,
|
|
2740
|
-
onCompletedResponse:
|
|
2854
|
+
onCompletedResponse: rememberPassthroughResponseChecked,
|
|
2855
|
+
onParsedPayload: noteInspectedPayload,
|
|
2741
2856
|
onFirstOutput: options.onFirstOutput,
|
|
2742
2857
|
pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled,
|
|
2743
2858
|
});
|
|
@@ -2788,6 +2903,7 @@ async function handleResponsesInner(
|
|
|
2788
2903
|
drainBounds: { ms: 15_000, bytes: 32 * 1024 * 1024 },
|
|
2789
2904
|
upstream,
|
|
2790
2905
|
pinCompletedResponseIdToFirstSeen: githubCopilotRepairEnabled,
|
|
2906
|
+
onParsedPayload: noteInspectedPayload,
|
|
2791
2907
|
};
|
|
2792
2908
|
if (recordTerminalOutcomes) {
|
|
2793
2909
|
// A real terminal was parsed from the (teed) inspection stream — record it as the outcome
|
|
@@ -2821,7 +2937,7 @@ async function handleResponsesInner(
|
|
|
2821
2937
|
() => unregisterTurn(turnAc),
|
|
2822
2938
|
logCtx,
|
|
2823
2939
|
() => options.onNativePassthroughCancel?.(),
|
|
2824
|
-
|
|
2940
|
+
rememberPassthroughResponseChecked,
|
|
2825
2941
|
options.onFirstOutput,
|
|
2826
2942
|
inspectionConsumerOptions,
|
|
2827
2943
|
);
|
|
@@ -2831,7 +2947,7 @@ async function handleResponsesInner(
|
|
|
2831
2947
|
logCtx,
|
|
2832
2948
|
turnAc.signal,
|
|
2833
2949
|
() => unregisterTurn(turnAc),
|
|
2834
|
-
|
|
2950
|
+
rememberPassthroughResponseChecked,
|
|
2835
2951
|
options.onFirstOutput,
|
|
2836
2952
|
inspectionConsumerOptions,
|
|
2837
2953
|
);
|
|
@@ -2865,30 +2981,41 @@ async function handleResponsesInner(
|
|
|
2865
2981
|
}
|
|
2866
2982
|
const text = bounded.text;
|
|
2867
2983
|
inspectResponseLogJson(logCtx, text);
|
|
2868
|
-
if (rememberPassthroughResponse) {
|
|
2869
|
-
try {
|
|
2870
|
-
rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
|
|
2871
|
-
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
2872
|
-
}
|
|
2873
2984
|
const clientJson = (() => {
|
|
2874
2985
|
const restored = restoreRoutedCustomCallsInJson(
|
|
2875
2986
|
restoreImageGenCallsInJson(text, imageGenCallAliases),
|
|
2876
2987
|
routedCustomToolNames,
|
|
2877
2988
|
);
|
|
2878
|
-
const repaired = (
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
try {
|
|
2882
|
-
outbound = JSON.parse(request.body);
|
|
2883
|
-
} catch {
|
|
2884
|
-
outbound = undefined;
|
|
2885
|
-
}
|
|
2886
|
-
return repairResponsesSnapshotJson(restored, outbound);
|
|
2887
|
-
})();
|
|
2989
|
+
const repaired = hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)
|
|
2990
|
+
? repairResponsesSnapshotJson(restored, outboundRequestBody)
|
|
2991
|
+
: restored;
|
|
2888
2992
|
return parsed._responseModelId !== undefined && parsed._responseModelId !== parsed.modelId
|
|
2889
2993
|
? rewriteResponsesModelJson(repaired, parsed._responseModelId)
|
|
2890
2994
|
: repaired;
|
|
2891
2995
|
})();
|
|
2996
|
+
// #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and
|
|
2997
|
+
// the reframed-SSE branch below are built from this body, so one check covers them. This
|
|
2998
|
+
// runs BEFORE the continuation cache write below: a refused turn must not become state a
|
|
2999
|
+
// later `previous_response_id` replay can expand from.
|
|
3000
|
+
if (undeclaredToolGuardActive) {
|
|
3001
|
+
const undeclared = (() => {
|
|
3002
|
+
try {
|
|
3003
|
+
return undeclaredToolCallNameInResponse(JSON.parse(clientJson), declaredWireToolNames);
|
|
3004
|
+
} catch {
|
|
3005
|
+
return undefined;
|
|
3006
|
+
}
|
|
3007
|
+
})();
|
|
3008
|
+
if (undeclared !== undefined) {
|
|
3009
|
+
return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared));
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
if (rememberPassthroughResponseChecked) {
|
|
3013
|
+
try {
|
|
3014
|
+
rememberPassthroughResponseChecked(
|
|
3015
|
+
JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown },
|
|
3016
|
+
);
|
|
3017
|
+
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
3018
|
+
}
|
|
2892
3019
|
// #875: the transport-neutral reliability policy forced a bounded JSON
|
|
2893
3020
|
// upstream for a client that asked for SSE. Reframe the completed JSON
|
|
2894
3021
|
// as the canonical terminal SSE sequence (created → output_item.done →
|