@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.
Files changed (84) hide show
  1. package/gui/dist/assets/index-CFqJKF2L.js +102 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/adapters/anthropic.ts +39 -7
  5. package/src/adapters/cursor/tool-definitions.ts +48 -0
  6. package/src/adapters/google.ts +18 -12
  7. package/src/adapters/openai-chat.ts +106 -13
  8. package/src/adapters/tool-call-id.ts +119 -0
  9. package/src/adapters/tool-catalog-nudge.ts +3 -0
  10. package/src/bridge.ts +16 -5
  11. package/src/chat/inbound.ts +5 -11
  12. package/src/claude/context-windows.ts +20 -4
  13. package/src/claude/desktop-3p.ts +11 -6
  14. package/src/claude/inbound.ts +39 -1
  15. package/src/claude/model-info.ts +28 -8
  16. package/src/cli/account-api.ts +5 -1
  17. package/src/cli/claude-desktop.ts +3 -0
  18. package/src/cli/config-command.ts +37 -14
  19. package/src/codex/app-server-restart-service.ts +1 -1
  20. package/src/codex/auth-api.ts +5 -0
  21. package/src/codex/auth-context.ts +43 -2
  22. package/src/codex/catalog/metadata.ts +135 -12
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +61 -13
  25. package/src/codex/catalog/provider-fetch.ts +37 -7
  26. package/src/codex/catalog/sync.ts +49 -25
  27. package/src/codex/catalog-refresh-status.ts +21 -3
  28. package/src/codex/catalog.ts +1 -1
  29. package/src/codex/convergence-types.ts +23 -2
  30. package/src/codex/desired-state.ts +1 -1
  31. package/src/codex/inject.ts +38 -7
  32. package/src/codex/injected-marker.ts +28 -0
  33. package/src/codex/journal.ts +40 -1
  34. package/src/codex/management-convergence.ts +55 -2
  35. package/src/codex/quota-rejection.ts +61 -1
  36. package/src/codex/quota.ts +60 -6
  37. package/src/codex/routing.ts +30 -3
  38. package/src/combos/failover.ts +20 -0
  39. package/src/config.ts +271 -4
  40. package/src/generated/compatibility-version.json +90 -78
  41. package/src/grok/sync.ts +3 -1
  42. package/src/lab/artifacts/sanitize.ts +1 -1
  43. package/src/lab/live/manifest.ts +1 -1
  44. package/src/lib/codex-restart-contract.ts +1 -1
  45. package/src/lib/config-ownership.ts +1 -0
  46. package/src/lib/errors.ts +9 -0
  47. package/src/lib/lab-activation.ts +1 -1
  48. package/src/lib/optional-shutdown-hooks.ts +1 -1
  49. package/src/lib/pinned-http.ts +7 -2
  50. package/src/lib/windows-elevation.ts +3 -3
  51. package/src/providers/quota.ts +10 -4
  52. package/src/providers/registry.ts +2 -2
  53. package/src/responses/parser.ts +42 -7
  54. package/src/responses/provider-opaque-metadata.ts +1 -1
  55. package/src/responses/thought-signature-replay.ts +261 -0
  56. package/src/router.ts +6 -1
  57. package/src/routing/compatibility/provider-slot.ts +1 -1
  58. package/src/routing/evaluator.ts +12 -2
  59. package/src/routing/health.ts +16 -5
  60. package/src/routing/history/schema.ts +1 -1
  61. package/src/routing/trace.ts +1 -1
  62. package/src/server/auth-cors.ts +96 -21
  63. package/src/server/chat-completions.ts +6 -2
  64. package/src/server/chat-native.ts +32 -6
  65. package/src/server/index.ts +5 -3
  66. package/src/server/management/agent-settings-routes.ts +26 -4
  67. package/src/server/management/config-routes.ts +79 -2
  68. package/src/server/management/context.ts +1 -1
  69. package/src/server/management/model-rows.ts +5 -0
  70. package/src/server/management/native-integration-routes.ts +4 -1
  71. package/src/server/management/provider-routes.ts +19 -0
  72. package/src/server/management/shared.ts +3 -3
  73. package/src/server/management-api.ts +13 -6
  74. package/src/server/passive-route-linker.ts +1 -1
  75. package/src/server/relay.ts +16 -0
  76. package/src/server/responses/compact.ts +10 -3
  77. package/src/server/responses/core.ts +160 -33
  78. package/src/server/responses/fetch-helpers.ts +34 -2
  79. package/src/server/responses/input-admission.ts +17 -9
  80. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  81. package/src/server/system-env.ts +4 -2
  82. package/src/service.ts +22 -7
  83. package/src/types.ts +35 -1
  84. 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/_plan/260814_lab_core_decoupling/.
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: "disk",
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/_plan/260814_lab_core_decoupling/020_request_path_gate.md
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";
@@ -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
- if (route.codexAccountMode === "direct") {
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 = headersForCodexAuthContext(req.headers, authCtx);
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
- if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
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: headersForCodexAuthContext(req.headers, authCtx),
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
- "request_too_large",
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(snapshotDefaultsRequest, translatorBudget)
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: rememberPassthroughResponse,
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
- rememberPassthroughResponse,
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
- rememberPassthroughResponse,
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
- if (!hasResponsesSnapshotRepair(route.provider.responsesSnapshotRepair)) return restored;
2880
- let outbound: unknown;
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 →