@bitkyc08/opencodex 2.27.0 → 2.28.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 (45) hide show
  1. package/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +59 -0
  5. package/src/adapters/base.ts +2 -0
  6. package/src/adapters/google-antigravity-replay.ts +16 -8
  7. package/src/adapters/google.ts +21 -4
  8. package/src/adapters/openai-chat.ts +151 -54
  9. package/src/adapters/openai-responses.ts +37 -0
  10. package/src/cli/index.ts +19 -6
  11. package/src/codex/account-usability.ts +3 -0
  12. package/src/codex/auth-api.ts +22 -5
  13. package/src/codex/auth-context.ts +55 -2
  14. package/src/codex/catalog/metadata.ts +17 -3
  15. package/src/codex/catalog/native-models.ts +22 -14
  16. package/src/codex/catalog/sync.ts +57 -11
  17. package/src/codex/convergence.ts +61 -13
  18. package/src/codex/model-entitlements.ts +353 -0
  19. package/src/codex/quota.ts +28 -3
  20. package/src/codex/routing.ts +14 -8
  21. package/src/generated/compatibility-version.json +51 -39
  22. package/src/lib/destination-policy.ts +47 -0
  23. package/src/lib/shadow-call.ts +15 -0
  24. package/src/oauth/index.ts +33 -5
  25. package/src/oauth/store.ts +11 -5
  26. package/src/providers/fastwire.ts +39 -8
  27. package/src/providers/quota.ts +9 -2
  28. package/src/providers/registry.ts +74 -5
  29. package/src/providers/service-tier.ts +16 -8
  30. package/src/responses/parser.ts +3 -9
  31. package/src/responses/tool-search-compat.ts +301 -0
  32. package/src/router.ts +7 -0
  33. package/src/routing/capability.ts +26 -9
  34. package/src/routing/compatibility/behavior.ts +41 -3
  35. package/src/server/chat-native.ts +11 -2
  36. package/src/server/index.ts +54 -8
  37. package/src/server/management/agent-settings-routes.ts +16 -2
  38. package/src/server/request-log.ts +31 -0
  39. package/src/server/responses/compact.ts +54 -7
  40. package/src/server/responses/core.ts +246 -39
  41. package/src/server/responses/responses-field-backfill.ts +88 -6
  42. package/src/server/responses/terminal-guard.ts +10 -0
  43. package/src/server/responses-tool-search-repair.ts +217 -0
  44. package/src/server/system-env.ts +74 -5
  45. package/src/usage/log.ts +4 -0
@@ -10,7 +10,7 @@
10
10
  * how that affects eligibility.
11
11
  */
12
12
 
13
- import type { OcxConfig } from "../types";
13
+ import { modelInList, type OcxConfig } from "../types";
14
14
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
15
15
  import { serviceTierSupportForModel } from "../providers/service-tier";
16
16
  import { PROVIDER_REGISTRY } from "../providers/registry";
@@ -21,6 +21,7 @@ import {
21
21
  nativeReasoningEfforts,
22
22
  } from "../codex/catalog/metadata";
23
23
  import { readCatalog, readCodexCatalogPath } from "../codex/catalog/parsing";
24
+ import { modelRecordValue } from "../reasoning-effort";
24
25
  import { statSync } from "node:fs";
25
26
  import type { RouteCapabilityEvidence } from "./trace";
26
27
 
@@ -159,9 +160,14 @@ export function candidateCapabilityEvidence(
159
160
  const catalogRow = cachedCatalogModels().find(model => model.provider === providerName && model.id === modelId);
160
161
  const isNative = providerName === OPENAI_CODEX_PROVIDER_ID && !modelId.includes("/");
161
162
 
162
- const rawContextWindow = provider?.modelContextWindows?.[modelId]
163
+ // `modelRecordValue`, not a bare lookup: every runtime reader of these three maps
164
+ // resolves them that way, so a `gpt-oss` entry covers `gpt-oss:120b`. Reading raw
165
+ // made the evidence disagree with the resolver it claims to describe — and for the
166
+ // window it did not even degrade to unknown, it fell through to the provider-wide
167
+ // value, which is a definite wrong answer rather than an absent one.
168
+ const rawContextWindow = modelRecordValue(provider?.modelContextWindows, modelId)
163
169
  ?? provider?.contextWindow
164
- ?? registryEntry?.modelContextWindows?.[modelId]
170
+ ?? modelRecordValue(registryEntry?.modelContextWindows, modelId)
165
171
  ?? catalogRow?.contextWindow
166
172
  ?? (isNative ? nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) : undefined);
167
173
  // Native rows go through the accessor (raise-to-ceiling + opt-in). Routed rows keep
@@ -170,10 +176,21 @@ export function candidateCapabilityEvidence(
170
176
  ? (nativeOpenAiContextWindow(modelId, nativeContextLimits(config)) ?? rawContextWindow)
171
177
  : rawContextWindow;
172
178
 
173
- const modalities = provider?.modelInputModalities?.[modelId]
174
- ?? registryEntry?.modelInputModalities?.[modelId]
175
- ?? catalogRow?.inputModalities
176
- ?? (isNative ? nativeInputModalities(modelId) : undefined);
179
+ // `noVisionModels` is checked before the modality chain because that is the order
180
+ // `isModelTextOnly` uses: it matches the no-vision list and returns true before it
181
+ // ever reads `modelInputModalities` (`src/vision/index.ts:32`). So a `gpt-oss`
182
+ // no-vision entry beats an exact `gpt-oss:120b` entry that lists "image", and
183
+ // deriving `image` from the modality chain alone reported vision on a model the
184
+ // runtime refuses it for. That matters more here than on the CLI surface fixed in
185
+ // #2086: routing *acts* on this evidence, so it would select the candidate for image
186
+ // work that execution then rejects.
187
+ const noVision = modelInList(provider?.noVisionModels, modelId);
188
+ const modalities = noVision
189
+ ? ["text"]
190
+ : (modelRecordValue(provider?.modelInputModalities, modelId)
191
+ ?? modelRecordValue(registryEntry?.modelInputModalities, modelId)
192
+ ?? catalogRow?.inputModalities
193
+ ?? (isNative ? nativeInputModalities(modelId) : undefined));
177
194
  const image = Array.isArray(modalities)
178
195
  ? modalities.includes("image")
179
196
  : undefined;
@@ -196,8 +213,8 @@ export function candidateCapabilityEvidence(
196
213
  || provider?.parallelToolCalls === true
197
214
  || undefined;
198
215
 
199
- const reasoningEfforts = provider?.modelReasoningEfforts?.[modelId]
200
- ?? registryEntry?.modelReasoningEfforts?.[modelId]
216
+ const reasoningEfforts = modelRecordValue(provider?.modelReasoningEfforts, modelId)
217
+ ?? modelRecordValue(registryEntry?.modelReasoningEfforts, modelId)
201
218
  ?? (isNative ? nativeReasoningEfforts(modelId) : undefined);
202
219
 
203
220
  const tierSupport = provider
@@ -1,3 +1,4 @@
1
+ import { modelRecordValue } from "../../reasoning-effort";
1
2
  import { modelInList } from "../../types";
2
3
  import type { OcxConfig, OcxProviderConfig } from "../../types";
3
4
  import { PROVIDER_REGISTRY } from "../../providers/registry";
@@ -51,8 +52,45 @@ function includesModel(list: string[] | undefined, modelId: string): boolean {
51
52
  return modelInList(list, modelId);
52
53
  }
53
54
 
55
+ /**
56
+ * Per-model override lookup for the nine family-aware report rows.
57
+ *
58
+ * Delegates to modelRecordValue so the report reads these maps the way the
59
+ * runtime does -- own properties only, then the pre-colon family, then a
60
+ * case-folded key. A bare index disagreed on all three: it missed the
61
+ * `gpt-oss` entry ollama-cloud's `gpt-oss:120b` actually resolves, missed a
62
+ * differently-cased key, and walked the prototype chain, so a routed model id
63
+ * of `constructor`/`toString` yielded an Object.prototype function. That last
64
+ * one made buildBehaviorFingerprintV1 throw ("unsupported value type
65
+ * function"); the caller catches it (`src/routing/compatibility/subject.ts:125`)
66
+ * and returns no route, so the subject is silently dropped -- and the linker
67
+ * contract says implementations do not throw.
68
+ *
69
+ * Not every override map belongs here. `modelPreferHostedTools` and
70
+ * `modelOpenRouterRouting` are exact-own at runtime and go through
71
+ * `exactOwnValue` below; widening those to the family would be this same bug
72
+ * with the sign flipped.
73
+ */
54
74
  function modelValue<T>(map: Record<string, T> | undefined, modelId: string): T | undefined {
55
- return map?.[modelId];
75
+ return modelRecordValue(map, modelId);
76
+ }
77
+
78
+ /**
79
+ * Exact, own-property lookup for the two maps the runtime resolves that way.
80
+ *
81
+ * `modelPreferHostedTools` and `modelOpenRouterRouting` are deliberately exact: the
82
+ * adapter reads the first through `hasOwnProperty`
83
+ * (`src/adapters/openai-responses.ts:1001`) and the second through `Object.hasOwn`
84
+ * (`src/providers/openrouter-routing.ts:89`), and the type documents the first as
85
+ * "Exact-model hosted tools" (`src/types.ts:1584`). Sending them through
86
+ * `modelRecordValue` would make the report say a `gpt-oss` entry applies to
87
+ * `gpt-oss:120b` when the adapter will never apply it -- the same divergence this
88
+ * file exists to remove, pointed the other way. A bare index is not the answer
89
+ * either: it walks the prototype chain, which is the bug `modelValue` just fixed.
90
+ * Neither existing primitive is right for these two, so this is the third one.
91
+ */
92
+ function exactOwnValue<T>(map: Record<string, T> | undefined, modelId: string): T | undefined {
93
+ return map !== undefined && Object.hasOwn(map, modelId) ? map[modelId] : undefined;
56
94
  }
57
95
 
58
96
  const CREDENTIAL_HEADER = /(authorization|api[-_]?key|token|secret|credential|cookie)/i;
@@ -69,7 +107,7 @@ function nonCredentialHeaderDigest(
69
107
  }
70
108
 
71
109
  function effectiveOpenRouterRouting(effective: OcxProviderConfig, modelId: string) {
72
- return effective.modelOpenRouterRouting?.[modelId] ?? effective.openRouterRouting;
110
+ return exactOwnValue(effective.modelOpenRouterRouting, modelId) ?? effective.openRouterRouting;
73
111
  }
74
112
 
75
113
  /**
@@ -184,7 +222,7 @@ export function resolveProductionBehaviorValues(
184
222
  effective.parallelToolCalls ?? (upstreamProtocol === "openai-chat"),
185
223
  ),
186
224
  "tools.hostedPreference": behaviorRow("provider_config", {
187
- tools: modelValue(effective.modelPreferHostedTools, modelId) ?? [],
225
+ tools: exactOwnValue(effective.modelPreferHostedTools, modelId) ?? [],
188
226
  }),
189
227
  "tools.builtinNameEscaping": behaviorRow("provider_config", effective.escapeBuiltinToolNames === true),
190
228
  "cache.forwarding": behaviorRow("provider_config", effective.promptCacheKey === true),
@@ -27,6 +27,7 @@ import {
27
27
  rateLimitRetryPolicyFor,
28
28
  rotateProviderTransportOn429,
29
29
  } from "../providers/key-failover";
30
+ import { fastPolicyForModel } from "../providers/service-tier";
30
31
  import type { RouteResult } from "../router";
31
32
  import type { OcxConfig, OcxProviderConfig } from "../types";
32
33
  import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers";
@@ -154,8 +155,16 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
154
155
  translatorBudget.chargeRetained(bytes, { kind: "request_copies" });
155
156
  retainedRequestBytes = bytes;
156
157
  };
158
+ const buildActiveRequest = () => buildOpenAIChatPassthroughRequest(
159
+ activeProvider,
160
+ options.chatBody,
161
+ route.modelId,
162
+ requestedStream,
163
+ fastPolicyForModel(activeProvider, route.modelId, route.providerName, "chat"),
164
+ config.fastMode,
165
+ );
157
166
  try {
158
- activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream);
167
+ activeRequest = buildActiveRequest();
159
168
  retainRequest(activeRequest);
160
169
  } catch (error) {
161
170
  releaseRetainedRequest();
@@ -222,7 +231,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
222
231
  activeProvider = rotated;
223
232
  activeAdapter = createOpenAIChatAdapter(activeProvider);
224
233
  releaseRetainedRequest();
225
- activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream);
234
+ activeRequest = buildActiveRequest();
226
235
  retainRequest(activeRequest);
227
236
  response = await send(activeRequest, "key-429");
228
237
  }
@@ -58,6 +58,12 @@ import {
58
58
  cooldownErrorMessage,
59
59
  } from "../codex/auth-context";
60
60
  import { codexAccountNamespaceForModel } from "../codex/account-namespace-match";
61
+ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../codex/account-namespaces";
62
+ import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
63
+ import {
64
+ availableAccountGatedNativeModels,
65
+ resolveCodexModelEntitlements,
66
+ } from "../codex/model-entitlements";
61
67
  export {
62
68
  clearThreadAccountMap,
63
69
  formatCodexProviderForLog,
@@ -899,8 +905,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
899
905
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
900
906
  }
901
907
  let goModels;
908
+ let modelEntitlements;
902
909
  try {
903
- goModels = await fetchAllModels(config);
910
+ [goModels, modelEntitlements] = await Promise.all([
911
+ fetchAllModels(config),
912
+ resolveCodexModelEntitlements(config),
913
+ ]);
904
914
  } catch (error) {
905
915
  if (error instanceof CatalogGatherBusyError) {
906
916
  return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
@@ -911,24 +921,57 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
911
921
  throw error;
912
922
  }
913
923
  const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
924
+ const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../codex/catalog/native-models");
914
925
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
915
926
  const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
927
+ const bareEligibleAccountIds = providerCodexAccountMode(
928
+ OPENAI_CODEX_PROVIDER_ID,
929
+ config.providers[OPENAI_CODEX_PROVIDER_ID],
930
+ ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
931
+ const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
932
+ modelEntitlements,
933
+ bareEligibleAccountIds,
934
+ );
935
+ const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
936
+ const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
937
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
938
+ ));
939
+ const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
940
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
941
+ ));
916
942
  const nativeSlugs = includeNativeOpenAi
917
- ? nativeOpenAiSlugs()
943
+ ? nativeOpenAiSlugs().filter(slug => (
944
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
945
+ ))
918
946
  : [];
919
947
  const disabledNatives = disabledNativeSlugs(config);
920
948
  const disabledModels = new Set(config.disabledModels ?? []);
921
949
  const shadowedNativeSlugs = configuredNativeAliasSlugs(config);
922
- const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
950
+ const suppressedBareNativeSlugs = new Set([
951
+ ...desktopAllowlistSuppressedNativeSlugs(config),
952
+ ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)),
953
+ ]);
923
954
  const accountSelectors = includeAccountBoundNativeOpenAi
924
955
  ? visibleCodexAccountSelectors(config)
925
956
  : [];
957
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
926
958
  const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi
927
- ? accountBoundNativeOpenAiSlugsBySelector(config)
959
+ ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => {
960
+ const target = accountTargets.get(selector);
961
+ const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
962
+ const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
963
+ const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
964
+ return [selector, slugs.filter(slug => (
965
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
966
+ ))] as const;
967
+ }))
928
968
  : new Map<string, readonly string[]>();
929
969
  const accountNativeSlugs = [...new Set(
930
970
  [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]),
931
971
  )];
972
+ const desktopNativeSlugs = desktopVisibleNativeSlugs(config).filter(slug => (
973
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
974
+ ));
932
975
  const goEnabled = filterCatalogVisibleModels(goModels, config);
933
976
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
934
977
  // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
@@ -945,7 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
945
988
  if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy);
946
989
  // Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
947
990
  buildDesktop3pRegistry(
948
- [...desktopVisibleNativeSlugs(config)],
991
+ desktopNativeSlugs,
949
992
  goOrdered.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
950
993
  config.claudeCode?.desktopProfile,
951
994
  );
@@ -962,7 +1005,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
962
1005
  : idsParam === "desktop"
963
1006
  ? "desktop3p" as const
964
1007
  : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
965
- const data = buildAnthropicModelInfos([...desktopVisibleNativeSlugs(config)], goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config));
1008
+ const data = buildAnthropicModelInfos(desktopNativeSlugs, goOrdered, resolveAutoContext(config.claudeCode), idStyle, activeDesktop3pAlias, nativeContextLimits(config));
966
1009
  return jsonResponse({ data }, 200, req, policy);
967
1010
  }
968
1011
  if (url.searchParams.has("client_version")) {
@@ -976,7 +1019,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
976
1019
  // newly re-enabled native reappear under each selector before the next sync, while the
977
1020
  // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior.
978
1021
  const catalogNativeSlugs = accountSelectors.length > 0
979
- ? [...new Set([...NATIVE_OPENAI_MODELS, ...accountNativeSlugs])]
1022
+ ? [...new Set([
1023
+ ...availableAccountNativeSlugs,
1024
+ ...accountNativeSlugs,
1025
+ ])]
980
1026
  : nativeSlugs;
981
1027
  const entries = buildCatalogEntries(
982
1028
  loadCatalogTemplate(),
@@ -1043,7 +1089,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1043
1089
  // for both bare and qualified rows. Without selectors, the live catalog continues to own
1044
1090
  // bare availability.
1045
1091
  const selectorNativeSlugs = accountSelectors.length > 0
1046
- ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug))
1092
+ ? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug))
1047
1093
  : [];
1048
1094
  const bareSelectorNativeSlugs = accountSelectors.length > 0
1049
1095
  ? selectorNativeSlugs
@@ -613,15 +613,29 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
613
613
  stored === catalogModelSlug(m) || slugEquals(stored, m.provider, m.id)
614
614
  ))
615
615
  .map(catalogModelSlug))];
616
- const available = [
616
+ const chosen = config.subagentModels ?? [];
617
+ const selectable = [
617
618
  ...listCatalogNativeSlugs().filter(ns => !disabled.has(ns)),
618
619
  ...visibleRouted,
619
620
  ];
621
+ // A saved roster slot must stay representable even after its model is disabled
622
+ // elsewhere (Models page, provider allowlist, a provider row going away). The
623
+ // dashboard treats `available` as the set of rows it can render, so a chosen id
624
+ // missing from it disappears from the roster UI and the next Save — which PUTs
625
+ // exactly what the UI holds — silently truncates the persisted list. Losing a
626
+ // deliberate 5-model roster to an unrelated visibility toggle is data loss, not a
627
+ // filter. Same reasoning as `fetchGrokCandidateModels`, which deliberately lists a
628
+ // model the user already excluded so its switch remains reachable.
629
+ const selectableSet = new Set(selectable);
630
+ const available = [
631
+ ...selectable,
632
+ ...[...new Set(chosen)].filter(model => !selectableSet.has(model)),
633
+ ];
620
634
  // #857: let CLI/GUI show when a running Codex app-server keeps an older
621
635
  // in-memory catalog than the one on disk.
622
636
  const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes");
623
637
  const catalogState = collectCodexAppServerCatalogState();
624
- return jsonResponse({ chosen: config.subagentModels ?? [], available, catalogState });
638
+ return jsonResponse({ chosen, available, catalogState });
625
639
  }
626
640
  if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
627
641
  let body: { models?: unknown };
@@ -65,6 +65,8 @@ export interface RequestLogContext {
65
65
  /** Stable non-PII Codex Pool account identity for durable usage attribution. */
66
66
  accountLogLabel?: string;
67
67
  requestedModel?: string;
68
+ /** Original bare helper model when the opt-in shadow-call route rewrote this request. */
69
+ shadowCallRewrittenFrom?: string;
68
70
  /** Internal structural combo identity; omitted from RequestLogEntry/JSONL. */
69
71
  comboId?: string;
70
72
  requestedEffort?: string;
@@ -142,6 +144,8 @@ export interface RequestLogEntry {
142
144
  /** Best-effort chat/session correlation for Logs grouping (#330). */
143
145
  conversationId?: string;
144
146
  requestedModel?: string;
147
+ /** Original bare helper model when the opt-in shadow-call route rewrote this request. */
148
+ shadowCallRewrittenFrom?: string;
145
149
  requestedEffort?: string;
146
150
  effectiveEffort?: string;
147
151
  reasoningWireField?: string;
@@ -255,6 +259,9 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
255
259
  ? { accountLogLabel: entry.accountLogLabel }
256
260
  : {}),
257
261
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
262
+ ...(entry.shadowCallRewrittenFrom
263
+ ? { shadowCallRewrittenFrom: entry.shadowCallRewrittenFrom }
264
+ : {}),
258
265
  ...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}),
259
266
  ...(entry.effectiveEffort ? { effectiveEffort: entry.effectiveEffort } : {}),
260
267
  ...(entry.reasoningWireField ? { reasoningWireField: entry.reasoningWireField } : {}),
@@ -327,6 +334,20 @@ export function hydrateRequestLogsFromDisk(
327
334
  }
328
335
 
329
336
  export function addRequestLog(entry: RequestLogEntry) {
337
+ // Sanitize ONCE, at the ingress, and use that one value for both destinations.
338
+ //
339
+ // `addFinalRequestLog` is not the only way in: `addRequestLog` is exported and callable
340
+ // directly, and it retained the caller's entry verbatim in the in-memory ring while only the
341
+ // field-by-field disk projection below saw a sanitized value. That split let `/api/logs`
342
+ // serve a raw upstream-supplied marker — a newline in it can forge a record boundary in a
343
+ // line-oriented viewer — while `usage.jsonl` looked clean, which is the worst shape for a
344
+ // sanitization bug because the safe surface is the one you check.
345
+ const shadowCallRewrittenFrom = sanitizeLogMetadataString(entry.shadowCallRewrittenFrom);
346
+ const retained: RequestLogEntry = shadowCallRewrittenFrom === entry.shadowCallRewrittenFrom
347
+ ? entry
348
+ : { ...entry, ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}) };
349
+ if (!shadowCallRewrittenFrom && retained !== entry) delete retained.shadowCallRewrittenFrom;
350
+ entry = retained;
330
351
  retainRequestLogEntry(entry);
331
352
  try {
332
353
  // Failure diagnostics survive the 200-entry ring buffer by riding the persisted
@@ -358,6 +379,9 @@ export function addRequestLog(entry: RequestLogEntry) {
358
379
  ...(entry.conversationId ? { conversationId: entry.conversationId } : {}),
359
380
  ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
360
381
  ...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
382
+ ...(entry.shadowCallRewrittenFrom
383
+ ? { shadowCallRewrittenFrom: entry.shadowCallRewrittenFrom }
384
+ : {}),
361
385
  ...(entry.requestedEffort ? { requestedEffort: entry.requestedEffort } : {}),
362
386
  ...(entry.effectiveEffort ? { effectiveEffort: entry.effectiveEffort } : {}),
363
387
  ...(entry.reasoningWireField ? { reasoningWireField: entry.reasoningWireField } : {}),
@@ -905,6 +929,12 @@ export function addFinalRequestLog(
905
929
  const loggedUsage = aggregate?.usage ?? existing.usage;
906
930
  const usageStatus = aggregate?.status ?? existing.status;
907
931
  const totalTokens = aggregate?.totalTokens ?? existing.totalTokens;
932
+ // Sanitize at the logging layer, not only at the one call site that populates this today.
933
+ // The value originates in an upstream-supplied model id, so an unsanitized newline would
934
+ // let a single field forge a record boundary in any line-oriented log viewer. Doing it here
935
+ // means a future caller cannot reintroduce the hole by forgetting to sanitize first, and
936
+ // the in-memory /api/logs row matches what usage.jsonl already stores.
937
+ const shadowCallRewrittenFrom = sanitizeLogMetadataString(logCtx.shadowCallRewrittenFrom);
908
938
  addLog({
909
939
  requestId,
910
940
  timestamp: start,
@@ -919,6 +949,7 @@ export function addFinalRequestLog(
919
949
  : {}),
920
950
  ...(logCtx.conversationId ? { conversationId: logCtx.conversationId } : {}),
921
951
  ...(logCtx.requestedModel ? { requestedModel: logCtx.requestedModel } : {}),
952
+ ...(shadowCallRewrittenFrom ? { shadowCallRewrittenFrom } : {}),
922
953
  ...(logCtx.requestedEffort ? { requestedEffort: logCtx.requestedEffort } : {}),
923
954
  ...(logCtx.effectiveEffort ? { effectiveEffort: logCtx.effectiveEffort } : {}),
924
955
  ...(logCtx.reasoningWireField ? { reasoningWireField: logCtx.reasoningWireField } : {}),
@@ -126,6 +126,7 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat
126
126
  import { codexAuthContextLogLabel } from "../../codex/account-label";
127
127
 
128
128
  import {
129
+ codexAccountGatedCanonicalWireModel,
129
130
  decodeRequestErrorResponse,
130
131
  handleResponses,
131
132
  preAuthUpstreamHostCircuitKey,
@@ -304,6 +305,12 @@ export async function handleResponsesCompact(
304
305
  return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
305
306
  }
306
307
  const selectedModelId = route.modelId;
308
+ // Derive from the RESOLVED route model, not the caller's raw string. An account-qualified
309
+ // selector like `side/gpt-daybreak-blue-latest` does not match the gated map — `slugsEquivalent`
310
+ // reads the account namespace as a routed provider prefix — so keying on `raw.model` sent
311
+ // exactly the selector form back down the native compact endpoint this guard exists to avoid.
312
+ // `route.modelId` is the same value `applyCodexAccountGatedWireNormalization` uses in core.ts.
313
+ const accountGatedCompactWireModel = codexAccountGatedCanonicalWireModel(selectedModelId);
307
314
  logCtx.requestedModel = raw.model;
308
315
  logCtx.model = selectedModelId;
309
316
  logCtx.routeDecision = route.routeDecision;
@@ -322,7 +329,10 @@ export async function handleResponsesCompact(
322
329
 
323
330
  // #1686: a bearer-presented admission secret is one of ours, so the stored main credential
324
331
  // is substituted below instead of the caller bearer being forwarded.
325
- const substituteMainCredential = admission?.source === "bearer";
332
+ // #2132: and only when the route is a native Codex one, which is the only route that can
333
+ // consume that credential. See the longer note in core.ts resolveResponsesCodexAuth.
334
+ const substituteMainCredential = admission?.source === "bearer"
335
+ && route.codexAccountMode !== undefined;
326
336
  if (route.codexAccountMode === "direct" && !substituteMainCredential) {
327
337
  try { validateForwardAdmissionCredential(req.headers, config); }
328
338
  catch (err) {
@@ -334,7 +344,7 @@ export async function handleResponsesCompact(
334
344
  // Native /responses/compact exists on the canonical ChatGPT backend and on the
335
345
  // official OpenAI API. Any other Responses-shaped gateway must take the routed
336
346
  // summarizer path below, or compaction fails against an endpoint it never had (#422).
337
- if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider)) {
347
+ if (supportsNativeResponsesCompactEndpoint(route.providerName, route.provider) && !accountGatedCompactWireModel) {
338
348
  if (req.signal.aborted) {
339
349
  return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
340
350
  }
@@ -368,6 +378,7 @@ export async function handleResponsesCompact(
368
378
  authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, {
369
379
  accountId: route.codexAccountId,
370
380
  modelId: selectedModelId,
381
+ substituteMainCredentialForDirect: substituteMainCredential,
371
382
  beginCodexAccountSelection: codexAccountSelectionForTurn(turnAdmissionLease),
372
383
  });
373
384
  logCtx.accountLogLabel = codexAuthContextLogLabel(authCtx, config);
@@ -665,7 +676,10 @@ export async function handleResponsesCompact(
665
676
  const inputItems = Array.isArray(raw.input) ? (raw.input as unknown[]) : [];
666
677
  const internalBody = {
667
678
  ...raw,
668
- stream: false,
679
+ // Canonical ChatGPT Responses rejects non-streaming turns. Daybreak cannot use the
680
+ // native compact endpoint either, so run its synthetic compaction as SSE and collapse
681
+ // the completed event back into the v1 compact JSON contract below.
682
+ stream: accountGatedCompactWireModel ? true : false,
669
683
  input: [...inputItems, { type: "compaction_trigger" }],
670
684
  };
671
685
  const internalHeaders = new Headers({ "content-type": "application/json" });
@@ -681,10 +695,36 @@ export async function handleResponsesCompact(
681
695
  const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) });
682
696
  if (!response.ok) return response;
683
697
  let json: { output?: unknown[]; status?: unknown; error?: unknown };
684
- try {
685
- json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown };
686
- } catch {
687
- return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response");
698
+ if (response.headers.get("content-type")?.includes("text/event-stream")) {
699
+ if (!response.body) {
700
+ return formatErrorResponse(502, "server_error", "compaction turn returned an empty event stream");
701
+ }
702
+ const terminal = { status: "incomplete" as "completed" | "failed" | "incomplete" };
703
+ let completed: { id?: unknown; output?: unknown; status?: unknown } | undefined;
704
+ await new Promise<void>(resolve => {
705
+ consumeForInspection(
706
+ response.body!,
707
+ status => { terminal.status = status; },
708
+ req.signal,
709
+ resolve,
710
+ undefined,
711
+ undefined,
712
+ value => { completed = value; },
713
+ );
714
+ });
715
+ if (req.signal.aborted) {
716
+ return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
717
+ }
718
+ if (terminal.status !== "completed" || !completed) {
719
+ return formatErrorResponse(502, "upstream_error", `compaction turn did not complete (status: ${terminal.status})`);
720
+ }
721
+ json = completed as { output?: unknown[]; status?: unknown; error?: unknown };
722
+ } else {
723
+ try {
724
+ json = await response.json() as { output?: unknown[]; status?: unknown; error?: unknown };
725
+ } catch {
726
+ return formatErrorResponse(502, "server_error", "compaction turn returned a non-JSON response");
727
+ }
688
728
  }
689
729
  // The internal turn answers 200 even when it failed or was truncated, so the body
690
730
  // has to be inspected. Reporting a failure beats installing "(no summary
@@ -713,6 +753,13 @@ export async function handleResponsesCompact(
713
753
  `compaction turn produced ${compactionItems.length} compaction items, expected exactly 1`,
714
754
  );
715
755
  }
756
+ // The canonical Responses stream returns a real OpenAI-encrypted compaction item. OCX cannot
757
+ // and should not decrypt it; /responses/compact callers can consume that item directly.
758
+ if (accountGatedCompactWireModel) {
759
+ return new Response(JSON.stringify({ output: compactionItems }), {
760
+ headers: { "Content-Type": "application/json" },
761
+ });
762
+ }
716
763
  const encrypted = compactionItems[0]!.encrypted_content;
717
764
  const decoded = typeof encrypted === "string" ? decodeCompactionSummary(encrypted) : null;
718
765
  // An empty `ocx1:` envelope decodes to "" rather than null, so length is what matters.