@bitkyc08/opencodex 2.26.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 (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
@@ -1,7 +1,8 @@
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";
4
- import { fastPolicyForModel, serviceTierSupportForModel } from "../../providers/service-tier";
5
+ import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier";
5
6
  import { resolveProviderAuthTransport } from "../../providers/fastwire";
6
7
  import { localFingerprint } from "../../lab/digest";
7
8
  import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types";
@@ -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
  /**
@@ -103,7 +141,7 @@ export function resolveProductionBehaviorValues(
103
141
  const project = typeof effective.project === "string" && effective.project ? effective.project : null;
104
142
  const location = typeof effective.location === "string" && effective.location ? effective.location : null;
105
143
  const nativeLocalExec = effective.nativeLocalExec === "on" || effective.unsafeAllowNativeLocalExec === true;
106
- const fastPolicy = fastPolicyForModel(effective, modelId, providerName);
144
+ const fastPolicy = fastPolicyForModel(effective, modelId, providerName, "responses", provider);
107
145
 
108
146
  const values: LabBehaviorValues = {
109
147
  "wire.adapter": behaviorRow("provider_config", adapter),
@@ -122,7 +160,7 @@ export function resolveProductionBehaviorValues(
122
160
  "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true),
123
161
  "responses.serviceTier": behaviorRow(
124
162
  "provider_config",
125
- serviceTierSupportForModel(effective, modelId, providerName) ?? null,
163
+ serviceTierSupportFromPolicy(fastPolicy) ?? null,
126
164
  ),
127
165
  "responses.fastWireKind": behaviorRow(
128
166
  "provider_config",
@@ -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),
@@ -13,7 +13,7 @@ import type {
13
13
  } from "../types";
14
14
  import { codexAccountNamespaceEntries } from "../codex/account-namespaces";
15
15
  import { listComboIds, resolveComboId } from "../combos";
16
- import { hasOwnProvider } from "../config";
16
+ import { hasOwnProvider } from "../config/provider-name";
17
17
  import { MAX_COMPATIBILITY_REQUIRED_SUITES } from "./compatibility/types";
18
18
  import { POLICY_NAMESPACE } from "./profile-namespace";
19
19
 
@@ -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,
@@ -705,6 +711,11 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
705
711
  ? startNativeMainStartupLifecycle(deps.nativeMainStartup)
706
712
  : blockNativeMainStartupForUnownedServiceHome(
707
713
  nativeOwnership.ownership === "foreign" ? "foreign-ownership" : "ownership-unknown",
714
+ // #2108: an `unknown` verdict means the probe could not answer, not that this host
715
+ // is unownable. Hand the fence a way to re-ask so a host that becomes answerable
716
+ // after boot reopens on its own instead of needing `ocx restart`. A `foreign`
717
+ // verdict ignores this by design — that one is a fact, not a question.
718
+ { reprobe: () => inspectStartupOwnership(deps).ownership },
708
719
  )
709
720
  : {
710
721
  homeId: null,
@@ -894,8 +905,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
894
905
  return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
895
906
  }
896
907
  let goModels;
908
+ let modelEntitlements;
897
909
  try {
898
- goModels = await fetchAllModels(config);
910
+ [goModels, modelEntitlements] = await Promise.all([
911
+ fetchAllModels(config),
912
+ resolveCodexModelEntitlements(config),
913
+ ]);
899
914
  } catch (error) {
900
915
  if (error instanceof CatalogGatherBusyError) {
901
916
  return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
@@ -906,24 +921,57 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
906
921
  throw error;
907
922
  }
908
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");
909
925
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
910
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
+ ));
911
942
  const nativeSlugs = includeNativeOpenAi
912
- ? nativeOpenAiSlugs()
943
+ ? nativeOpenAiSlugs().filter(slug => (
944
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
945
+ ))
913
946
  : [];
914
947
  const disabledNatives = disabledNativeSlugs(config);
915
948
  const disabledModels = new Set(config.disabledModels ?? []);
916
949
  const shadowedNativeSlugs = configuredNativeAliasSlugs(config);
917
- const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
950
+ const suppressedBareNativeSlugs = new Set([
951
+ ...desktopAllowlistSuppressedNativeSlugs(config),
952
+ ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)),
953
+ ]);
918
954
  const accountSelectors = includeAccountBoundNativeOpenAi
919
955
  ? visibleCodexAccountSelectors(config)
920
956
  : [];
957
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
921
958
  const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi
922
- ? 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
+ }))
923
968
  : new Map<string, readonly string[]>();
924
969
  const accountNativeSlugs = [...new Set(
925
970
  [...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]),
926
971
  )];
972
+ const desktopNativeSlugs = desktopVisibleNativeSlugs(config).filter(slug => (
973
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
974
+ ));
927
975
  const goEnabled = filterCatalogVisibleModels(goModels, config);
928
976
  const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
929
977
  // Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
@@ -940,7 +988,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
940
988
  if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy);
941
989
  // Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
942
990
  buildDesktop3pRegistry(
943
- [...desktopVisibleNativeSlugs(config)],
991
+ desktopNativeSlugs,
944
992
  goOrdered.map(m => ({ provider: m.provider, id: m.id, contextWindow: m.contextWindow })),
945
993
  config.claudeCode?.desktopProfile,
946
994
  );
@@ -957,7 +1005,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
957
1005
  : idsParam === "desktop"
958
1006
  ? "desktop3p" as const
959
1007
  : (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
960
- 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));
961
1009
  return jsonResponse({ data }, 200, req, policy);
962
1010
  }
963
1011
  if (url.searchParams.has("client_version")) {
@@ -971,7 +1019,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
971
1019
  // newly re-enabled native reappear under each selector before the next sync, while the
972
1020
  // no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior.
973
1021
  const catalogNativeSlugs = accountSelectors.length > 0
974
- ? [...new Set([...NATIVE_OPENAI_MODELS, ...accountNativeSlugs])]
1022
+ ? [...new Set([
1023
+ ...availableAccountNativeSlugs,
1024
+ ...accountNativeSlugs,
1025
+ ])]
975
1026
  : nativeSlugs;
976
1027
  const entries = buildCatalogEntries(
977
1028
  loadCatalogTemplate(),
@@ -1038,7 +1089,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
1038
1089
  // for both bare and qualified rows. Without selectors, the live catalog continues to own
1039
1090
  // bare availability.
1040
1091
  const selectorNativeSlugs = accountSelectors.length > 0
1041
- ? NATIVE_OPENAI_MODELS.filter(slug => !disabledNatives.has(slug))
1092
+ ? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug))
1042
1093
  : [];
1043
1094
  const bareSelectorNativeSlugs = accountSelectors.length > 0
1044
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 };
@@ -86,7 +86,8 @@ export type CostEstimateReason =
86
86
  | "usage_estimated"
87
87
  | "cache_detail_missing"
88
88
  | "expected_price_overlay"
89
- | "provider_cost_overlay";
89
+ | "provider_cost_overlay"
90
+ | "priority_lower_bound";
90
91
 
91
92
  export type CostResult =
92
93
  | { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
@@ -143,6 +144,7 @@ export function costResult(entry: MetricSource): CostResult {
143
144
  ? "expected_price_overlay" as const : undefined,
144
145
  estimate.price?.source === "user" || estimate.attempts?.some(a => a.price.source === "user")
145
146
  ? "provider_cost_overlay" as const : undefined,
147
+ estimate.priorityLowerBound ? "priority_lower_bound" as const : undefined,
146
148
  ].filter((reason): reason is CostEstimateReason => reason !== undefined);
147
149
  return { kind: "value", estimate, estimateReasons };
148
150
  }
@@ -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 } : {}),
@@ -101,22 +101,23 @@ import type { TranslatorBudget } from "../../lib/translator-budget";
101
101
 
102
102
 
103
103
  export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: TranslatorBudget): {
104
- toolNsMap: Map<string, { namespace: string; name: string }>;
104
+ toolNsMap: Map<string, { namespace: string; name: string; freeform?: true }>;
105
105
  declaredToolNames: Set<string>;
106
106
  /** Declared parameter schema per request-visible tool name (#1611 integer repair). */
107
107
  toolParameterSchemas: Map<string, Record<string, unknown>>;
108
108
  freeformToolNames: Set<string>;
109
109
  toolSearchToolNames: Set<string>;
110
110
  } {
111
- const toolNsMap = new Map<string, { namespace: string; name: string }>();
111
+ const toolNsMap = new Map<string, { namespace: string; name: string; freeform?: true }>();
112
112
  const declaredToolNames = new Set<string>();
113
113
  const toolParameterSchemas = new Map<string, Record<string, unknown>>();
114
114
  const freeformToolNames = new Set<string>();
115
115
  const toolSearchToolNames = new Set<string>();
116
- const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice);
117
- for (const t of parsed.context.tools ?? []) {
116
+ const requestedTools = parsed.context.tools ?? [];
117
+ const toolAllowed = toolChoiceToolPredicate(parsed.options.toolChoice, requestedTools);
118
+ const authorizedTools = requestedTools.filter(toolAllowed);
119
+ for (const t of authorizedTools) {
118
120
  // Upstream output is untrusted: only restore calls for tools the caller authorized.
119
- if (!toolAllowed(t)) continue;
120
121
  const wireName = namespacedToolName(t.namespace, t.name);
121
122
  budget?.chargeRetained(new TextEncoder().encode(wireName).byteLength, { kind: "retained_collectors" });
122
123
  declaredToolNames.add(wireName);
@@ -125,7 +126,7 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
125
126
  if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(wireName, t.parameters);
126
127
  if (t.namespace) {
127
128
  budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([wireName, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
128
- toolNsMap.set(wireName, { namespace: t.namespace, name: t.name });
129
+ toolNsMap.set(wireName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
129
130
  }
130
131
  if (t.freeform) {
131
132
  budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
@@ -136,6 +137,29 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
136
137
  toolSearchToolNames.add(t.name);
137
138
  }
138
139
  }
140
+ // Some routed providers echo a bare tool_choice selector instead of the flattened catalog
141
+ // name. Accept only selectors the client actually sent and only when the full request catalog
142
+ // contains one tool with that logical name.
143
+ const choice = parsed.options.toolChoice;
144
+ const bareChoiceNames = new Set(
145
+ choice && typeof choice === "object"
146
+ ? ("allowedTools" in choice ? choice.allowedTools : [choice.name])
147
+ : [],
148
+ );
149
+ const bareNameCounts = new Map<string, number>();
150
+ for (const t of requestedTools) {
151
+ bareNameCounts.set(t.name, (bareNameCounts.get(t.name) ?? 0) + 1);
152
+ }
153
+ for (const t of authorizedTools) {
154
+ if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
155
+ budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
156
+ declaredToolNames.add(t.name);
157
+ budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
158
+ toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
159
+ if (t.parameters && typeof t.parameters === "object") {
160
+ toolParameterSchemas.set(t.name, t.parameters);
161
+ }
162
+ }
139
163
  return { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames };
140
164
  }
141
165
 
@@ -197,7 +221,8 @@ export interface MultiAgentGuidanceDeps {
197
221
  configuredModels: readonly string[],
198
222
  surface: SpawnAgentSurface,
199
223
  ) => EffectiveSubagentRoster | Promise<EffectiveSubagentRoster>;
200
- collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" };
224
+ collectCatalogState?: () => { state: "fresh" | "stale" | "not_running" | "unknown" }
225
+ | Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }>;
201
226
  }
202
227
 
203
228
  async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale" | "not_running" | "unknown" }> {
@@ -207,8 +232,8 @@ async function defaultCollectCatalogState(): Promise<{ state: "fresh" | "stale"
207
232
  if (override === "fresh" || override === "stale" || override === "not_running" || override === "unknown") {
208
233
  return { state: override };
209
234
  }
210
- const { collectCodexAppServerCatalogState } = await import("../../codex/app-server-processes");
211
- return collectCodexAppServerCatalogState();
235
+ const { collectCodexAppServerCatalogStateForRequest } = await import("../../codex/app-server-processes");
236
+ return collectCodexAppServerCatalogStateForRequest();
212
237
  }
213
238
 
214
239
 
@@ -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.