@bitkyc08/opencodex 2.22.0 → 2.23.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 (79) hide show
  1. package/gui/dist/assets/{index-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
  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 +5 -1
  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 +55 -8
  23. package/src/codex/catalog/native-models.ts +32 -2
  24. package/src/codex/catalog/parsing.ts +21 -7
  25. package/src/codex/catalog/provider-fetch.ts +35 -7
  26. package/src/codex/catalog/sync.ts +40 -18
  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 +86 -74
  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/providers/quota.ts +10 -4
  50. package/src/providers/registry.ts +2 -2
  51. package/src/responses/parser.ts +42 -7
  52. package/src/responses/provider-opaque-metadata.ts +1 -1
  53. package/src/responses/thought-signature-replay.ts +261 -0
  54. package/src/router.ts +6 -1
  55. package/src/routing/compatibility/provider-slot.ts +1 -1
  56. package/src/routing/evaluator.ts +12 -2
  57. package/src/routing/health.ts +16 -5
  58. package/src/routing/history/schema.ts +1 -1
  59. package/src/routing/trace.ts +1 -1
  60. package/src/server/auth-cors.ts +56 -21
  61. package/src/server/chat-completions.ts +6 -2
  62. package/src/server/index.ts +5 -3
  63. package/src/server/management/agent-settings-routes.ts +26 -4
  64. package/src/server/management/context.ts +1 -1
  65. package/src/server/management/model-rows.ts +5 -0
  66. package/src/server/management/native-integration-routes.ts +4 -1
  67. package/src/server/management/provider-routes.ts +19 -0
  68. package/src/server/management/shared.ts +3 -3
  69. package/src/server/management-api.ts +13 -6
  70. package/src/server/passive-route-linker.ts +1 -1
  71. package/src/server/relay.ts +16 -0
  72. package/src/server/responses/compact.ts +10 -3
  73. package/src/server/responses/core.ts +160 -33
  74. package/src/server/responses/fetch-helpers.ts +34 -2
  75. package/src/server/responses/input-admission.ts +17 -9
  76. package/src/server/responses-undeclared-tool-guard.ts +153 -0
  77. package/src/server/system-env.ts +4 -2
  78. package/src/service.ts +14 -7
  79. package/src/types.ts +34 -0
@@ -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 →
@@ -33,7 +33,7 @@ import {
33
33
  import { isInjectionDebugEnabled } from "../../lib/debug-settings";
34
34
  import { injectionDebugLog } from "../../lib/injection-debug-log";
35
35
  import { modelInList, namespacedToolName } from "../../types";
36
- import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
36
+ import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage, UpstreamHttpVersion } from "../../types";
37
37
  import {
38
38
  forceRefreshOAuthAccessSnapshot,
39
39
  getOAuthCredentialApiBaseUrl,
@@ -149,6 +149,38 @@ export interface ProviderFetchOptions {
149
149
  modelId?: string;
150
150
  }
151
151
 
152
+ /**
153
+ * Bun's fetch accepts a non-standard `protocol` init to pin the HTTP version
154
+ * (BunFetchRequestInit.protocol). The DOM lib types do not include it, so the
155
+ * value is carried on an intersection and stripped before non-Bun callers.
156
+ * The accepted values are the shared UPSTREAM_HTTP_VERSION_VALUES enum from types.
157
+ */
158
+ const UPSTREAM_HTTP_VERSION_PROTOCOL: Record<Exclude<UpstreamHttpVersion, "auto">, string> = {
159
+ "http1.1": "http1.1",
160
+ h1: "h1",
161
+ http2: "http2",
162
+ h2: "h2",
163
+ };
164
+
165
+ /** Attach Bun's `protocol` pin when the provider opted into a fixed HTTP version. */
166
+ export function withUpstreamHttpVersion(
167
+ input: Parameters<typeof globalThis.fetch>[0],
168
+ init: RequestInit | undefined,
169
+ provider: OcxProviderConfig,
170
+ ): RequestInit | undefined {
171
+ const version = provider.upstreamHttpVersion;
172
+ if (!version || version === "auto") return init;
173
+ // Bun's protocol pin requires an https: target; local/plaintext upstreams keep
174
+ // their existing transport untouched.
175
+ const target = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
176
+ try {
177
+ if (new URL(target).protocol !== "https:") return init;
178
+ } catch {
179
+ return init;
180
+ }
181
+ return { ...(init ?? {}), protocol: UPSTREAM_HTTP_VERSION_PROTOCOL[version] } as RequestInit;
182
+ }
183
+
152
184
  export function providerFetch(
153
185
  provider: OcxProviderConfig,
154
186
  runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
@@ -162,7 +194,7 @@ export function providerFetch(
162
194
  if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
163
195
  return codexWsUpstreamFetch(input, init, base, runtime);
164
196
  }
165
- return base(input, init);
197
+ return base(input, withUpstreamHttpVersion(input, init, provider));
166
198
  };
167
199
  const waitForPacing = (signal?: AbortSignal) => options.providerName
168
200
  ? waitForProviderRequestSlot(options.providerName, provider, options.modelId, signal)
@@ -10,7 +10,7 @@
10
10
  * catches the pathological case and stays out of the way otherwise. Every uncertainty
11
11
  * resolves toward admitting.
12
12
  */
13
- import { nativeOpenAiContextWindow } from "../../codex/catalog/metadata";
13
+ import { nativeOpenAiContextWindow, nativeOpenAiMaxInputTokens } from "../../codex/catalog/metadata";
14
14
  import { estimateTokens } from "../../lib/token-estimate";
15
15
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
16
16
  import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../types";
@@ -129,6 +129,9 @@ export function resolveInputCeiling(
129
129
  provider: OcxProviderConfig,
130
130
  providerName: string,
131
131
  modelId: string,
132
+ // Operator cap for the canonical native provider. Passed in rather than read from a
133
+ // config here so this stays pure: no filesystem, no catalog, no registry scan.
134
+ nativeContextCap?: number,
132
135
  ): number | null {
133
136
  const configured = positive(provider.modelContextWindows?.[modelId]) ?? positive(provider.contextWindow);
134
137
 
@@ -137,18 +140,22 @@ export function resolveInputCeiling(
137
140
  // transport-mismatched custom provider named "openai" is preserved verbatim by routing
138
141
  // and must not inherit built-in native limits, and a routed `provider/model` id is not a
139
142
  // native slug. Static maps only — no catalog read.
140
- const native = configured === null
141
- && providerName === OPENAI_CODEX_PROVIDER_ID
143
+ const canonicalNativeBare = providerName === OPENAI_CODEX_PROVIDER_ID
142
144
  && isCanonicalOpenAiForwardProvider(provider)
143
- && !modelId.includes("/")
144
- ? positive(nativeOpenAiContextWindow(modelId))
145
+ && !modelId.includes("/");
146
+ const native = configured === null && canonicalNativeBare
147
+ ? positive(nativeOpenAiContextWindow(modelId, nativeContextCap))
145
148
  : null;
149
+ // The model's own measured input ceiling applies even when the operator configured a
150
+ // window: a 1,050,000 override must not raise the gate above the 922,000 the upstream
151
+ // actually accepts. Computed independently of `configured` for exactly that reason.
152
+ const nativeMaxInput = canonicalNativeBare ? positive(nativeOpenAiMaxInputTokens(modelId, nativeContextCap)) : null;
146
153
 
147
154
  const window = configured ?? native;
148
155
  // modelMaxInputTokens is an input-only cap, so it can only tighten the window.
149
- const maxInput = positive(provider.modelMaxInputTokens?.[modelId]);
150
- if (window === null) return maxInput;
151
- return maxInput === null ? window : Math.min(window, maxInput);
156
+ const configuredMaxInput = positive(provider.modelMaxInputTokens?.[modelId]);
157
+ const limits = [window, configuredMaxInput, nativeMaxInput].filter((v): v is number => v !== null);
158
+ return limits.length === 0 ? null : Math.min(...limits);
152
159
  }
153
160
 
154
161
  /**
@@ -161,8 +168,9 @@ export function checkInputAdmission(
161
168
  provider: OcxProviderConfig,
162
169
  providerName: string,
163
170
  modelId: string,
171
+ nativeContextCap?: number,
164
172
  ): InputAdmissionResult {
165
- const ceiling = resolveInputCeiling(provider, providerName, modelId);
173
+ const ceiling = resolveInputCeiling(provider, providerName, modelId, nativeContextCap);
166
174
  if (ceiling === null) return { admitted: true, estimatedTokens: 0, ceiling: null };
167
175
  const estimatedTokens = estimateInputTokens(parsed, modelId);
168
176
  return { admitted: estimatedTokens <= ceiling * ADMISSION_TOLERANCE, estimatedTokens, ceiling };
@@ -0,0 +1,153 @@
1
+ import { namespacedToolName } from "../types";
2
+ import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
3
+
4
+ /**
5
+ * Item types the CLIENT executes by name. Hosted calls (`web_search_call`,
6
+ * `image_generation_call`, `local_shell_call`, `tool_search_call`, …) are run upstream or
7
+ * carry no tool name, so they are never matched against the request catalog.
8
+ */
9
+ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
10
+
11
+ /** An upstream-supplied name reaches the error message; keep it bounded. */
12
+ const MAX_REPORTED_NAME_CHARS = 100;
13
+
14
+ export const UNDECLARED_TOOL_CALL_ERROR_CODE = "undeclared_tool_call";
15
+
16
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
17
+ return !!value && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ function addWireToolName(names: Set<string>, tool: unknown, namespace?: string): void {
21
+ if (!isPlainObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return;
22
+ names.add(tool.name);
23
+ // Codex routes MCP calls by an explicit `namespace` field, so the same tool is reachable
24
+ // as a bare inner name or as the flattened form; accept both rather than guess which
25
+ // coordinate system this provider echoes back.
26
+ if (namespace) names.add(namespacedToolName(namespace, tool.name));
27
+ }
28
+
29
+ function addWireToolSpecs(names: Set<string>, specs: unknown): void {
30
+ if (!Array.isArray(specs)) return;
31
+ for (const spec of specs) {
32
+ if (!isPlainObject(spec)) continue;
33
+ if (spec.type === "namespace" && Array.isArray(spec.tools)) {
34
+ const namespace = typeof spec.name === "string" ? spec.name : undefined;
35
+ for (const inner of spec.tools) addWireToolName(names, inner, namespace);
36
+ continue;
37
+ }
38
+ addWireToolName(names, spec);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Tool names the OUTBOUND Responses body actually declared.
44
+ *
45
+ * This reads the body that goes upstream rather than the parsed internal tool list: the
46
+ * passthrough forwards wire shapes (namespaced MCP groups, `additional_tools` items carried
47
+ * inside `input`, routed custom-tool rewrites) that the internal list flattens or renames, and
48
+ * only the wire names can be compared against what the provider echoes back.
49
+ */
50
+ export function collectDeclaredWireToolNames(body: unknown): Set<string> {
51
+ const names = new Set<string>();
52
+ if (!isPlainObject(body)) return names;
53
+ addWireToolSpecs(names, body.tools);
54
+ if (Array.isArray(body.input)) {
55
+ for (const item of body.input) {
56
+ if (isPlainObject(item) && item.type === "additional_tools") addWireToolSpecs(names, item.tools);
57
+ }
58
+ }
59
+ return names;
60
+ }
61
+
62
+ function undeclaredNameInItem(item: unknown, declared: ReadonlySet<string>): string | undefined {
63
+ if (!isPlainObject(item)) return undefined;
64
+ if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined;
65
+ const name = item.name;
66
+ if (typeof name !== "string" || name.length === 0) return undefined;
67
+ if (declared.has(name)) return undefined;
68
+ if (typeof item.namespace === "string" && declared.has(namespacedToolName(item.namespace, name))) {
69
+ return undefined;
70
+ }
71
+ return name;
72
+ }
73
+
74
+ /** First undeclared client tool named by a Responses SSE payload, or undefined. */
75
+ export function undeclaredToolCallName(
76
+ payload: unknown,
77
+ declared: ReadonlySet<string>,
78
+ ): string | undefined {
79
+ if (!isPlainObject(payload)) return undefined;
80
+ if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") {
81
+ return undeclaredNameInItem(payload.item, declared);
82
+ }
83
+ // Sparse gateways skip incremental items and only ever ship the terminal snapshot.
84
+ if (payload.type === "response.completed" || payload.type === "response.incomplete") {
85
+ return undeclaredToolCallNameInResponse(payload.response, declared);
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ /** First undeclared client tool in a Responses object's `output` array, or undefined. */
91
+ export function undeclaredToolCallNameInResponse(
92
+ response: unknown,
93
+ declared: ReadonlySet<string>,
94
+ ): string | undefined {
95
+ if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined;
96
+ for (const item of response.output) {
97
+ const name = undeclaredNameInItem(item, declared);
98
+ if (name !== undefined) return name;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ export function undeclaredToolCallMessage(name: string): string {
104
+ const reported = name.slice(0, MAX_REPORTED_NAME_CHARS);
105
+ return `routed provider emitted undeclared client tool "${reported}"; only request-declared tools may be called`;
106
+ }
107
+
108
+ function failedBlocks(name: string, newline: string): readonly string[] {
109
+ const failure = {
110
+ type: "upstream_error",
111
+ code: UNDECLARED_TOOL_CALL_ERROR_CODE,
112
+ message: undeclaredToolCallMessage(name),
113
+ };
114
+ const payload = JSON.stringify({
115
+ type: "response.failed",
116
+ response: { status: "failed", error: failure, last_error: failure },
117
+ });
118
+ return [`event: response.failed${newline}data: ${payload}`, "data: [DONE]"];
119
+ }
120
+
121
+ /**
122
+ * Fail closed when a routed provider calls a tool the request never declared (#1700).
123
+ *
124
+ * The bridged paths already refuse such a call (`declaredToolNames` in src/bridge.ts), but the
125
+ * native Responses passthrough relayed it verbatim: Codex received a `function_call` for a tool
126
+ * it has no top-level handler for — `apply_patch`, which under code mode exists only as a nested
127
+ * `tools.apply_patch(...)` helper inside `exec` — and the turn surfaced as a bare `aborted` with
128
+ * no output and no explanation. Replacing the offending event with an explicit `response.failed`
129
+ * turns that silent dead end into a compatibility error naming the tool.
130
+ *
131
+ * Everything after the trip is dropped so a later `response.completed` cannot contradict the
132
+ * terminal already sent. Non-JSON and non-item blocks pass through untouched.
133
+ */
134
+ export function createUndeclaredToolCallGuardBlockRewrite(
135
+ declared: ReadonlySet<string>,
136
+ ): SseBlockRewrite {
137
+ let tripped = false;
138
+ return (block: string) => {
139
+ if (tripped) return [];
140
+ const payload = sseDataPayload(block);
141
+ if (payload === null || payload === "[DONE]") return [block];
142
+ let parsed: unknown;
143
+ try {
144
+ parsed = JSON.parse(payload);
145
+ } catch {
146
+ return [block];
147
+ }
148
+ const name = undeclaredToolCallName(parsed, declared);
149
+ if (name === undefined) return [block];
150
+ tripped = true;
151
+ return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n");
152
+ };
153
+ }
@@ -7,6 +7,8 @@ import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionToke
7
7
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
8
8
  import type { OcxConfig } from "../types";
9
9
  import { recordOwnedConfigPath } from "../lib/config-ownership";
10
+ import { providerContextCap } from "../providers/context-cap";
11
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
10
12
 
11
13
  /**
12
14
  * Does the opencodex dummy marker belong in the system environment?
@@ -237,10 +239,10 @@ async function computeEffectiveModelEnv(config: OcxConfig, auto?: AutoContextMod
237
239
  const windows = await boundedContextWindows(async () => {
238
240
  const { gatherRoutedModels, visibleNativeSlugs } = await import("../codex/catalog");
239
241
  try {
240
- return buildClaudeContextWindows([...visibleNativeSlugs(config)], await gatherRoutedModels(config));
242
+ return buildClaudeContextWindows([...visibleNativeSlugs(config)], await gatherRoutedModels(config), providerContextCap(config, OPENAI_CODEX_PROVIDER_ID));
241
243
  } catch (error) {
242
244
  if (error && typeof error === "object" && (error as { code?: unknown }).code === "catalog_busy") {
243
- return buildClaudeContextWindows([...visibleNativeSlugs(config)], []);
245
+ return buildClaudeContextWindows([...visibleNativeSlugs(config)], [], providerContextCap(config, OPENAI_CODEX_PROVIDER_ID));
244
246
  }
245
247
  throw error;
246
248
  }
package/src/service.ts CHANGED
@@ -9,7 +9,7 @@ import { execFileSync, execSync, spawnSync } from "node:child_process";
9
9
  import { findLiveProxy, proxyIdentityAt, SERVICE_STOP_LIVENESS } from "./server/proxy-liveness";
10
10
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
11
11
  import { homedir, tmpdir } from "node:os";
12
- import { dirname, join, resolve, win32 } from "node:path";
12
+ import { dirname, join, posix, resolve, win32 } from "node:path";
13
13
  import { expandUserPath, getConfigDir, readPid, removePid, removeRuntimePort, verifyPidIdentity } from "./config";
14
14
  import { loadConfig } from "./config";
15
15
  import { restoreNativeCodex, restoreNativeCodexAsync } from "./codex/inject";
@@ -113,12 +113,19 @@ function currentCodexSqliteHomeAbsolute(target: "native" | "windows" = "native")
113
113
  const raw = process.env.CODEX_SQLITE_HOME?.trim();
114
114
  if (!raw) return undefined;
115
115
  const expanded = expandUserPath(raw);
116
- // Windows service artifacts can be rendered by cross-platform tests and
117
- // repair tooling. Preserve an already-absolute drive/UNC path instead of
118
- // anchoring it beneath the current POSIX worktree.
119
- return target === "windows" && win32.isAbsolute(expanded)
120
- ? win32.normalize(expanded)
121
- : resolve(expanded);
116
+ // Service artifacts can be rendered by cross-platform tests and repair tooling, so an
117
+ // already-absolute path for the TARGET platform is preserved rather than re-anchored
118
+ // against the writing host. `resolve()` is host-relative in both directions: on a POSIX
119
+ // host it turns `C:\data` into `<cwd>/C:\data`, and on a Windows host it turns `/tmp/x`
120
+ // into `D:\tmp\x` — neither is a path the target can use. A relative value still resolves,
121
+ // because a service unit has no meaningful working directory.
122
+ //
123
+ // CODEX_HOME and OPENCODEX_HOME are carried through literally, so without this the same
124
+ // generated file disagreed with itself about two variables holding the same kind of value.
125
+ if (target === "windows") {
126
+ return win32.isAbsolute(expanded) ? win32.normalize(expanded) : resolve(expanded);
127
+ }
128
+ return posix.isAbsolute(expanded) ? posix.normalize(expanded) : resolve(expanded);
122
129
  }
123
130
 
124
131
  function currentOpenCodexHome(): string {