@bitkyc08/opencodex 2.55.0 → 2.56.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 (167) hide show
  1. package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +2 -1
  4. package/src/adapters/base.ts +21 -0
  5. package/src/adapters/cursor/transport-retry.ts +46 -1
  6. package/src/adapters/cursor.ts +4 -0
  7. package/src/adapters/kiro/adapter.ts +42 -1
  8. package/src/adapters/kiro-retry.ts +23 -4
  9. package/src/adapters/openai-chat/errors.ts +116 -0
  10. package/src/adapters/openai-chat/messages.ts +346 -0
  11. package/src/adapters/openai-chat/passthrough.ts +146 -0
  12. package/src/adapters/openai-chat/response-events.ts +117 -0
  13. package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
  14. package/src/adapters/openai-chat/tool-schema.ts +477 -0
  15. package/src/adapters/openai-chat/wire.ts +50 -0
  16. package/src/adapters/openai-chat.ts +33 -1445
  17. package/src/adapters/openai-responses/canonical-forward.ts +202 -0
  18. package/src/adapters/openai-responses/image-gen.ts +406 -0
  19. package/src/adapters/openai-responses/internal.ts +3 -0
  20. package/src/adapters/openai-responses/passthrough.ts +611 -0
  21. package/src/adapters/openai-responses/prompt-cache.ts +83 -0
  22. package/src/adapters/openai-responses/reasoning.ts +220 -0
  23. package/src/adapters/openai-responses/request-strips.ts +185 -0
  24. package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
  25. package/src/adapters/openai-responses/tool-schema.ts +293 -0
  26. package/src/adapters/openai-responses/web-search.ts +156 -0
  27. package/src/adapters/openai-responses.ts +4 -2625
  28. package/src/bridge/errors.ts +34 -0
  29. package/src/bridge/internal.ts +174 -0
  30. package/src/bridge/response-json.ts +624 -0
  31. package/src/bridge/sse.ts +1444 -0
  32. package/src/bridge.ts +5 -2204
  33. package/src/chat/inbound.ts +12 -1
  34. package/src/codex/account-lifecycle.ts +3 -0
  35. package/src/codex/account-store.ts +71 -9
  36. package/src/codex/auth-api/account-list.ts +507 -0
  37. package/src/codex/auth-api/http.ts +32 -0
  38. package/src/codex/auth-api/login-flow.ts +554 -0
  39. package/src/codex/auth-api/login-state.ts +64 -0
  40. package/src/codex/auth-api/main-account-probe.ts +331 -0
  41. package/src/codex/auth-api/pool-mode-gate.ts +274 -0
  42. package/src/codex/auth-api/pool-quota-probe.ts +512 -0
  43. package/src/codex/auth-api/reset-credit-service.ts +422 -0
  44. package/src/codex/auth-api/routes.ts +425 -0
  45. package/src/codex/auth-api/runtime-config.ts +48 -0
  46. package/src/codex/auth-api.ts +27 -3118
  47. package/src/codex/auth-context.ts +95 -28
  48. package/src/codex/catalog/auto-review.ts +507 -0
  49. package/src/codex/catalog/build-entries.ts +981 -0
  50. package/src/codex/catalog/combo-member.ts +375 -0
  51. package/src/codex/catalog/derive-entry.ts +229 -0
  52. package/src/codex/catalog/effort.ts +0 -1
  53. package/src/codex/catalog/gated-native-warn.ts +63 -0
  54. package/src/codex/catalog/gather-capture.ts +533 -0
  55. package/src/codex/catalog/model-hints.ts +691 -0
  56. package/src/codex/catalog/model-visibility.ts +304 -0
  57. package/src/codex/catalog/provider-fetch.ts +52 -2942
  58. package/src/codex/catalog/provider-models.ts +685 -0
  59. package/src/codex/catalog/restore.ts +132 -0
  60. package/src/codex/catalog/retained-sync.ts +706 -0
  61. package/src/codex/catalog/routed-gather.ts +858 -0
  62. package/src/codex/catalog/subagent-roster.ts +176 -0
  63. package/src/codex/catalog/sync.ts +52 -2698
  64. package/src/codex/inject/config-toml.ts +563 -0
  65. package/src/codex/inject/remove.ts +192 -0
  66. package/src/codex/inject/restore.ts +540 -0
  67. package/src/codex/inject/routing-classify.ts +109 -0
  68. package/src/codex/inject/routing-target.ts +125 -0
  69. package/src/codex/inject.ts +81 -1436
  70. package/src/codex/lineage.ts +458 -0
  71. package/src/codex/pool-refresh-backoff.ts +152 -0
  72. package/src/codex/routing/active-account.ts +194 -0
  73. package/src/codex/routing/cooldown-math.ts +275 -0
  74. package/src/codex/routing/health-store.ts +402 -0
  75. package/src/codex/routing/probe-lease.ts +358 -0
  76. package/src/codex/routing/selection.ts +703 -0
  77. package/src/codex/routing/thread-affinity.ts +538 -0
  78. package/src/codex/routing.ts +353 -2234
  79. package/src/codex/shim-fingerprint.ts +223 -0
  80. package/src/codex/shim-inspect.ts +175 -0
  81. package/src/codex/shim-probe.ts +367 -0
  82. package/src/codex/shim-restore-lock.ts +169 -0
  83. package/src/codex/shim-state-file.ts +151 -0
  84. package/src/codex/shim-templates.ts +265 -0
  85. package/src/codex/shim.ts +48 -1268
  86. package/src/config/diagnostics.ts +705 -0
  87. package/src/config/feature-flags.ts +55 -0
  88. package/src/config/live-reconcile.ts +403 -0
  89. package/src/config/load-degrade.ts +880 -0
  90. package/src/config/mutation-lock.ts +244 -0
  91. package/src/config/openai-tier-backup.ts +268 -0
  92. package/src/config/persist-unlocked.ts +92 -0
  93. package/src/config/proxy-env.ts +188 -0
  94. package/src/config/salvage.ts +244 -0
  95. package/src/config/schema/config-schema.ts +640 -0
  96. package/src/config/schema/leaf-validators.ts +855 -0
  97. package/src/config/warn-memo.ts +28 -0
  98. package/src/config.ts +234 -4481
  99. package/src/generated/compatibility-version.json +539 -39
  100. package/src/lib/request-execution-budget.ts +69 -20
  101. package/src/lib/spend-reservation-ledger.ts +940 -0
  102. package/src/lib/upstream-retry.ts +55 -11
  103. package/src/lib/workflow-budget.ts +553 -30
  104. package/src/providers/quota/account-cache.ts +441 -0
  105. package/src/providers/quota/antigravity.ts +295 -0
  106. package/src/providers/quota/report-cache.ts +320 -0
  107. package/src/providers/quota/vendor-probes-key.ts +1243 -0
  108. package/src/providers/quota/vendor-probes-oauth.ts +590 -0
  109. package/src/providers/quota.ts +324 -3079
  110. package/src/providers/registry/entries-core.ts +1221 -0
  111. package/src/providers/registry/entries-extended.ts +1204 -0
  112. package/src/providers/registry/model-seeds.ts +908 -0
  113. package/src/providers/registry/types.ts +352 -0
  114. package/src/providers/registry.ts +24 -3536
  115. package/src/responses/continuation-ownership.ts +29 -0
  116. package/src/responses/state/replay-fingerprint.ts +80 -0
  117. package/src/responses/state/snapshot-codec.ts +104 -0
  118. package/src/responses/state/spill-failure.ts +118 -0
  119. package/src/responses/state/spill-queue.ts +665 -0
  120. package/src/responses/state/temp-recovery.ts +257 -0
  121. package/src/responses/state.ts +82 -1143
  122. package/src/routing/identity-domains.ts +449 -0
  123. package/src/routing/probe-lease.ts +511 -0
  124. package/src/server/index/bounded-request.ts +88 -0
  125. package/src/server/index/live-sideband.ts +565 -0
  126. package/src/server/index/serve-options.ts +1766 -0
  127. package/src/server/index/startup-warnings.ts +213 -0
  128. package/src/server/index/websocket-handler.ts +335 -0
  129. package/src/server/index.ts +40 -2547
  130. package/src/server/management/route-registry.ts +26 -23
  131. package/src/server/management/shared.ts +8 -5
  132. package/src/server/management/workflow-budget-routes.ts +133 -0
  133. package/src/server/management-api.ts +12 -0
  134. package/src/server/request-log-conversation.ts +9 -7
  135. package/src/server/request-log.ts +245 -1
  136. package/src/server/responses/account-change-state.ts +233 -0
  137. package/src/server/responses/adapter-continuation.ts +514 -0
  138. package/src/server/responses/adapter-delivery.ts +214 -0
  139. package/src/server/responses/adapter-dispatch.ts +971 -0
  140. package/src/server/responses/compact.ts +59 -4
  141. package/src/server/responses/completion-policy.ts +33 -0
  142. package/src/server/responses/core-auth.ts +527 -0
  143. package/src/server/responses/core-codex-account.ts +859 -0
  144. package/src/server/responses/core-combo-failure.ts +210 -0
  145. package/src/server/responses/core-combo.ts +707 -0
  146. package/src/server/responses/core-errors.ts +152 -0
  147. package/src/server/responses/core-lifetime.ts +95 -0
  148. package/src/server/responses/core-normalize.ts +350 -0
  149. package/src/server/responses/core-opaque-recovery.ts +380 -0
  150. package/src/server/responses/core-options.ts +159 -0
  151. package/src/server/responses/core-replay.ts +225 -0
  152. package/src/server/responses/core.ts +192 -8893
  153. package/src/server/responses/passthrough-delivery.ts +856 -0
  154. package/src/server/responses/passthrough-dispatch.ts +1476 -0
  155. package/src/server/responses/passthrough-execution.ts +54 -0
  156. package/src/server/responses/request-prepare.ts +970 -0
  157. package/src/server/responses/request-send-budget.ts +164 -0
  158. package/src/server/responses/request-sidecar-auth.ts +149 -0
  159. package/src/server/responses/request-transport.ts +744 -0
  160. package/src/server/responses/response-effects.ts +157 -0
  161. package/src/server/responses/run-turn-execution.ts +448 -0
  162. package/src/server/responses/sidecar-execution.ts +469 -0
  163. package/src/server/responses-image-gen-repair.ts +1 -1
  164. package/src/server/workflow-refusal.ts +84 -0
  165. package/src/types/config.ts +30 -0
  166. package/src/usage/log.ts +146 -0
  167. package/src/usage/summary.ts +171 -21
@@ -1,5 +1,5 @@
1
1
  import type { PoolQuotaWriter } from "./quota-types";
2
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createHash, timingSafeEqual } from "node:crypto";
3
3
  import {
4
4
  CodexCredentialGenerationConflictError,
5
5
  CodexCredentialRefreshLockTimeoutError,
@@ -40,6 +40,12 @@ import {
40
40
  resolveCodexAccountForThreadDetailed,
41
41
  type CodexAffinityDecision,
42
42
  } from "./routing";
43
+ import {
44
+ codexConversationIdentity,
45
+ recordCodexThreadLineage,
46
+ resolveCodexThreadLineage,
47
+ type CodexThreadLineage,
48
+ } from "./lineage";
43
49
  import {
44
50
  entitledCodexAccountIdsForModel,
45
51
  isDirectCallerEntitledToCodexModel,
@@ -53,7 +59,6 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainP
53
59
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
54
60
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
55
61
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
56
- import { retainedUtf8Bytes } from "../lib/admission";
57
62
  import { extractAccountId, extractEmail } from "../oauth/chatgpt";
58
63
  import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
59
64
  import {
@@ -70,9 +75,6 @@ import type { DataPlaneAdmission } from "../server/auth-cors";
70
75
  import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
71
76
  import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
72
77
 
73
- const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
74
- const CODEX_APP_AFFINITY_KEY = randomBytes(32);
75
-
76
78
  /**
77
79
  * A request-owned bearer cannot inspect the physical main credential for its plan, but cached
78
80
  * WHAM usage is still valid routing evidence for the same logical main account. Score it with
@@ -88,32 +90,90 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
88
90
  return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
89
91
  }
90
92
 
91
- function boundedCodexAffinityComponent(value: string | null): string | undefined {
92
- const normalized = value?.trim();
93
- if (!normalized) return undefined;
94
- if (retainedUtf8Bytes(normalized) > CODEX_AFFINITY_COMPONENT_MAX_BYTES) return undefined;
95
- return normalized;
93
+ /**
94
+ * Every thread keys as ITSELF, never as its parent (#4546, wp8).
95
+ *
96
+ * The old rule preferred `x-codex-parent-thread-id`, so every child of one parent bound under
97
+ * the RAW parent id -- one shared entry, unrelated to the root's own `app:HMAC(session, thread)`
98
+ * binding -- and a grandchild keyed on its own parent landed on a key nobody had ever bound.
99
+ * A child therefore started cold while its parent was being served warm somewhere, and no
100
+ * child could hold a binding of its own.
101
+ *
102
+ * Now a request with a `thread-id` keys as HMAC(session ?? parent, thread). A root is
103
+ * unchanged, a child gets an independent key, and a request naming only a parent rides the
104
+ * parent's lane under HMAC(parent, parent) -- the same one-to-one lane it always had, minus
105
+ * the caller-supplied identifier that used to sit in Pool state. Which requests produce no
106
+ * key at all is unchanged. First placement for a child is what consults the family, through
107
+ * `recordCodexThreadLineage` below and the placement hook in ./routing.
108
+ *
109
+ * The derivation itself lives in ./lineage so a lineage record's conversation key and the key
110
+ * the thread actually binds under can never drift apart.
111
+ */
112
+ export function codexPoolAffinityKey(headers: Headers, now = Date.now()): string | undefined {
113
+ // `now` is threaded rather than read inside because a parent-only turn resolves its key
114
+ // through the recorded lineage, and that record is TTL-bounded: a caller working against a
115
+ // fixed clock would otherwise see a live record as expired and fall back to a key the parent
116
+ // never bound under.
117
+ return codexConversationIdentity(headers, now)?.conversationKey;
118
+ }
119
+
120
+ /** What a caller needs to know to answer the Pool-state question below before auth has run. */
121
+ export interface CodexPoolStateEligibility {
122
+ /** An exact account selector from the route, i.e. `options.accountId` here. */
123
+ readonly accountId?: string;
124
+ readonly modelId?: string;
125
+ readonly admission?: Pick<DataPlaneAdmission, "source">;
126
+ /** The caller presented its own forwardable ChatGPT credential for this route. */
127
+ readonly requestScopedMainCredential?: boolean;
128
+ }
129
+
130
+ /** The one expression both the resolution below and any preview must agree on. */
131
+ function poolStateEligible(
132
+ fixedAccountId: string | undefined,
133
+ requestScopedMainCredential: boolean,
134
+ ): boolean {
135
+ return fixedAccountId === undefined && !requestScopedMainCredential;
96
136
  }
97
137
 
98
138
  /**
99
- * Preserve Codex's parent-thread affinity when present. Desktop App requests can omit that
100
- * header while retaining a stable session/thread pair, so derive an opaque process-local key
101
- * only from the complete bounded pair. Raw identifiers and durable hashes never enter Pool state.
139
+ * May this request own Pool affinity state at all?
140
+ *
141
+ * Two credentials authenticate outside the Pool: an exact account selector (including the
142
+ * Reserve pin) and a request-owned main bearer, which exists for one request and must never
143
+ * fold into durable account state. Neither may read or write a binding, so neither may read
144
+ * or write LINEAGE either.
145
+ *
146
+ * Exported so that a preview asks the question with the code that answers it, instead of a
147
+ * restatement that can drift. It drifted once already: preview read a family relation from raw
148
+ * request headers before this function had decided anything, so it could follow a Pool family
149
+ * binding while the resolution below deliberately created no affinity -- and model fallback then
150
+ * evaluated eligibility against an account the request would never be authenticated as.
151
+ */
152
+ export function codexPoolStateEligible(
153
+ headers: Headers,
154
+ policy: CodexAuthPolicyConfig | undefined,
155
+ options: CodexPoolStateEligibility = {},
156
+ ): boolean {
157
+ const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission);
158
+ return poolStateEligible(
159
+ reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId,
160
+ options.requestScopedMainCredential === true && hasCallerCodexBearer(headers),
161
+ );
162
+ }
163
+
164
+ /**
165
+ * The lineage a PREVIEW is allowed to see: read-only, and only for a request that may hold Pool
166
+ * state. Recording is left to the resolution that actually binds, so a preview can never leave a
167
+ * record behind for a request that turned out to own no Pool state at all.
102
168
  */
103
- export function codexPoolAffinityKey(headers: Headers): string | undefined {
104
- const parentThreadId = boundedCodexAffinityComponent(headers.get("x-codex-parent-thread-id"));
105
- if (parentThreadId) return parentThreadId;
106
-
107
- const sessionId = boundedCodexAffinityComponent(headers.get("session-id"));
108
- const threadId = boundedCodexAffinityComponent(headers.get("thread-id"));
109
- if (!sessionId || !threadId) return undefined;
110
-
111
- return `app:${createHmac("sha256", CODEX_APP_AFFINITY_KEY)
112
- .update("opencodex-app-pool-affinity-v1\0")
113
- .update(sessionId)
114
- .update("\0")
115
- .update(threadId)
116
- .digest("base64url")}`;
169
+ export function previewCodexPoolLineage(
170
+ headers: Headers,
171
+ policy: CodexAuthPolicyConfig | undefined,
172
+ options: CodexPoolStateEligibility = {},
173
+ ): CodexThreadLineage | undefined {
174
+ return codexPoolStateEligible(headers, policy, options)
175
+ ? resolveCodexThreadLineage(headers)
176
+ : undefined;
117
177
  }
118
178
 
119
179
  export type CodexAuthContext =
@@ -798,9 +858,15 @@ export async function resolveCodexAuthContext(
798
858
  // A caller bearer can still accompany a request that selects a configured Pool account. Do not
799
859
  // let that request read, delete, or create a file-main affinity binding while deciding whether a
800
860
  // stored account is available; only the stored credential selected below may own Pool state.
801
- const affinityKey = fixedAccountId === undefined && !requestScopedMainCredential
861
+ const affinityKey = poolStateEligible(fixedAccountId, requestScopedMainCredential)
802
862
  ? codexPoolAffinityKey(headers)
803
863
  : undefined;
864
+ // The thread's family relation, recorded under the same condition as the key itself. A
865
+ // first-placing child consults it; a request-owned or fixed credential never enters Pool
866
+ // state, so it never enters lineage either.
867
+ const lineage = affinityKey !== undefined
868
+ ? recordCodexThreadLineage(headers)
869
+ : undefined;
804
870
  // Why this request is on this account, carried to the request log so a move reads as an event
805
871
  // instead of something inferred from account labels across lines (#4546).
806
872
  let affinityDecision: CodexAffinityDecision | undefined;
@@ -873,6 +939,7 @@ export async function resolveCodexAuthContext(
873
939
  quotaScope,
874
940
  selectionOptions,
875
941
  options.modelId,
942
+ lineage,
876
943
  );
877
944
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
878
945
  const selected = resolution.status === "selected" ? resolution.accountId : null;
@@ -0,0 +1,507 @@
1
+ import { redactSecretString } from "../../lib/redact";
2
+ import type { OcxConfig } from "../../types";
3
+ import { encodeRoutedModelId } from "../../providers/slug-codec";
4
+ import { canonicalAutoReviewModelKey, isValidAutoReviewModel as isValidAutoReviewTarget } from "../../config/provider-validation";
5
+ import { readConfiguredAutoReviewModel } from "./parsing";
6
+ import type { RawEntry } from "./parsing";
7
+ import { configuredCatalogEntry } from "./subagent-roster";
8
+
9
+ const AUTO_REVIEW_ROOT_MARKER = "opencodex_auto_review_root";
10
+
11
+ interface RootAutoReviewStamp {
12
+ slug: string;
13
+ original: string | null;
14
+ applied: string;
15
+ }
16
+
17
+ function rootAutoReviewStamp(entry: RawEntry): RootAutoReviewStamp | undefined {
18
+ const value = entry[AUTO_REVIEW_ROOT_MARKER];
19
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
20
+ const stamp = value as Record<string, unknown>;
21
+ if (stamp.slug !== entry.slug || typeof stamp.slug !== "string"
22
+ || typeof stamp.applied !== "string"
23
+ || (stamp.original !== null && typeof stamp.original !== "string")) return undefined;
24
+ return stamp as unknown as RootAutoReviewStamp;
25
+ }
26
+
27
+
28
+ /** True when the value is a valid Codex catalog auto-review selector. */
29
+ export function isValidAutoReviewModel(value: unknown): value is string {
30
+ return isValidAutoReviewTarget(value);
31
+ }
32
+
33
+ export type AutoReviewModelOverrideResult = "absent" | "applied" | "invalid" | "unresolved";
34
+
35
+ /** True when a catalog row was synthesized by opencodex instead of coming from upstream. */
36
+ function isRoutedCatalogEntry(entry: RawEntry): boolean {
37
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
38
+ return slug.includes("/")
39
+ || (typeof entry.description === "string" && entry.description.startsWith("Routed via opencodex → "));
40
+ }
41
+
42
+ /** Restore an owned native value, retaining provenance to avoid legacy reclassification. */
43
+ function clearAutoReviewOverrideValue(entry: RawEntry): void {
44
+ const stamp = rootAutoReviewStamp(entry);
45
+ if (stamp) {
46
+ if (entry.auto_review_model_override === stamp.applied) entry.auto_review_model_override = stamp.original;
47
+ } else {
48
+ entry.auto_review_model_override = null;
49
+ delete entry[AUTO_REVIEW_ROOT_MARKER];
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Legacy whole-catalog root stamp: releases before AUTO_REVIEW_ROOT_MARKER wrote root stamps that
55
+ * are textually identical to an upstream value, so the only way to recognize one is the uniform
56
+ * signature the no-provider path relies on — a single value that a routed row also carries.
57
+ * Returns the stamped values when the observed rows match that shape.
58
+ */
59
+ function legacyRootStampValues(observedModels: readonly RawEntry[]): ReadonlySet<string> | undefined {
60
+ if (observedModels.some(entry => entry?.[AUTO_REVIEW_ROOT_MARKER] !== undefined)) return undefined;
61
+ const configuredValues = new Set(observedModels.flatMap(entry => {
62
+ const value = entry?.auto_review_model_override;
63
+ return typeof value === "string" && value.trim() ? [value] : [];
64
+ }));
65
+ const globalStamp = configuredValues.size === 1
66
+ && observedModels.some(entry => {
67
+ const value = entry.auto_review_model_override;
68
+ return isRoutedCatalogEntry(entry)
69
+ && typeof value === "string"
70
+ && value.trim().length > 0
71
+ && configuredValues.has(value);
72
+ })
73
+ && observedModels.every(entry => {
74
+ const value = entry?.auto_review_model_override;
75
+ return value === null
76
+ || value === undefined
77
+ || (typeof value === "string" && configuredValues.has(value));
78
+ });
79
+ return globalStamp ? configuredValues : undefined;
80
+ }
81
+
82
+ /**
83
+ * Sweep legacy root stamps off the rows a root removal owns, before provider plans land.
84
+ *
85
+ * Root removal reaches marker-tagged native rows on its own, but a catalog written before the
86
+ * marker only carries the legacy signature — and provider stamping rewrites that signature before
87
+ * the root pass could read it, so the sweep has to run first.
88
+ */
89
+ function clearLegacyRootStamps(models: readonly RawEntry[], sourceModels: readonly RawEntry[] = []): void {
90
+ const legacyStamp = legacyRootStampValues([...models, ...sourceModels]);
91
+ if (legacyStamp === undefined) return;
92
+ for (const entry of models) {
93
+ if (!entry || typeof entry !== "object") continue;
94
+ const current = entry.auto_review_model_override;
95
+ if (entry[AUTO_REVIEW_ROOT_MARKER] === undefined
96
+ && typeof current === "string" && legacyStamp.has(current)) clearAutoReviewOverrideValue(entry);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Clear the root selector from every row this path owns: routed rows, rows stamped by a release
102
+ * that writes the provenance marker, and the legacy whole-catalog stamp that predates it.
103
+ */
104
+ function clearAutoReviewModelOverride(
105
+ models: readonly RawEntry[],
106
+ sourceModels: readonly RawEntry[] = [],
107
+ ): void {
108
+ const legacyStamp = legacyRootStampValues([...models, ...sourceModels]);
109
+ for (const entry of models) {
110
+ if (!entry || typeof entry !== "object") continue;
111
+ const current = entry.auto_review_model_override;
112
+ if (isRoutedCatalogEntry(entry)
113
+ || (entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry) !== undefined)
114
+ || (legacyStamp !== undefined && typeof current === "string" && legacyStamp.has(current))) {
115
+ clearAutoReviewOverrideValue(entry);
116
+ }
117
+ }
118
+ }
119
+
120
+ /** Warn once about a malformed or unresolvable root auto-review selector. */
121
+ function warnAutoReviewModelDiagnostic(
122
+ reason: "invalid" | "unresolved",
123
+ configured: string,
124
+ ): void {
125
+ const safeConfigured = JSON.stringify(redactSecretString(configured));
126
+ const detail = reason === "unresolved"
127
+ ? "the selector was not found in the final catalog"
128
+ : "the selector format is invalid";
129
+ console.warn(
130
+ `[opencodex] auto_review_model ${detail} (${safeConfigured}); preserving normal upstream auto-review behavior.`,
131
+ );
132
+ }
133
+
134
+ /** Warn once about a malformed or unresolvable provider-scoped auto-review selector. */
135
+ function warnProviderAutoReviewModelDiagnostic(
136
+ reason: "invalid" | "unresolved",
137
+ provider: string,
138
+ configured: string,
139
+ ): void {
140
+ const safeProvider = JSON.stringify(redactSecretString(provider));
141
+ const safeConfigured = JSON.stringify(redactSecretString(configured));
142
+ const detail = reason === "unresolved"
143
+ ? "the selector was not found in the final catalog"
144
+ : "the selector format is invalid";
145
+ console.warn(
146
+ `[opencodex] auto_review_model for provider ${safeProvider} ${detail} (${safeConfigured}); using the next valid provider/root selector or upstream behavior.`,
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Note once when a bare selector resolves to a row outside the provider it was configured on.
152
+ *
153
+ * That is how a native model is named as a reviewer, so it stays usable, but a mistyped target must
154
+ * not be silent: the operator sees which catalog row actually supplies the reviewer.
155
+ */
156
+ function warnProviderAutoReviewForeignTarget(provider: string, configured: string, target: string): void {
157
+ const safeProvider = JSON.stringify(redactSecretString(provider));
158
+ const safeConfigured = JSON.stringify(redactSecretString(configured));
159
+ const safeTarget = JSON.stringify(redactSecretString(target));
160
+ console.warn(
161
+ `[opencodex] auto_review_model for provider ${safeProvider} (${safeConfigured}) resolved to ${safeTarget}, which is not a row of that provider; that catalog row supplies the reviewer.`,
162
+ );
163
+ }
164
+
165
+ /** Preserve native upstream overrides and the root-derived provenance marker from source rows. */
166
+ function preserveNativeAutoReviewModelOverrides(
167
+ models: readonly RawEntry[],
168
+ sourceModels: readonly RawEntry[],
169
+ ): void {
170
+ const existing = new Map<string, { value: string | null; root: true | RootAutoReviewStamp | undefined }>();
171
+ for (const entry of sourceModels) {
172
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
173
+ const value = entry.auto_review_model_override;
174
+ if (!slug || isRoutedCatalogEntry(entry)) continue;
175
+ if (typeof value === "string" || value === null) {
176
+ existing.set(slug, { value, root: rootAutoReviewStamp(entry) ?? (entry[AUTO_REVIEW_ROOT_MARKER] === true ? true : undefined) });
177
+ }
178
+ }
179
+ for (const entry of models) {
180
+ const slug = typeof entry.slug === "string" ? entry.slug : undefined;
181
+ if (!slug || isRoutedCatalogEntry(entry) || !existing.has(slug)) continue;
182
+ const saved = existing.get(slug)!;
183
+ entry.auto_review_model_override = saved.value;
184
+ if (saved.root) entry[AUTO_REVIEW_ROOT_MARKER] = structuredClone(saved.root);
185
+ else delete entry[AUTO_REVIEW_ROOT_MARKER];
186
+ }
187
+ }
188
+
189
+ /** Stamp a root-derived override and mark native rows so later root removal is durable. */
190
+ function stampRootAutoReviewOverride(entry: RawEntry, target: string): void {
191
+ if (!isRoutedCatalogEntry(entry)) {
192
+ const previous = rootAutoReviewStamp(entry);
193
+ const current = entry.auto_review_model_override;
194
+ entry[AUTO_REVIEW_ROOT_MARKER] = {
195
+ slug: typeof entry.slug === "string" ? entry.slug : "",
196
+ original: previous && current === previous.applied
197
+ ? previous.original : typeof current === "string" ? current : null,
198
+ applied: target,
199
+ } satisfies RootAutoReviewStamp;
200
+ } else {
201
+ delete entry[AUTO_REVIEW_ROOT_MARKER];
202
+ }
203
+ entry.auto_review_model_override = target;
204
+ }
205
+
206
+ /** Stamp a provider-derived override; provider stamps never fall under root removal. */
207
+ function stampProviderAutoReviewOverride(entry: RawEntry, target: string): void {
208
+ entry.auto_review_model_override = target;
209
+ delete entry[AUTO_REVIEW_ROOT_MARKER];
210
+ }
211
+
212
+ /**
213
+ * Apply the root Codex auto-review selector to every catalog row, or clear it when the value is
214
+ * absent, blank, malformed, or does not resolve against the assembled catalog.
215
+ */
216
+ export function applyAutoReviewModelOverride(
217
+ models: RawEntry[] | undefined,
218
+ autoReviewModel: string | null | undefined,
219
+ sourceModels: readonly RawEntry[] = [],
220
+ ): AutoReviewModelOverrideResult {
221
+ if (!models || !Array.isArray(models)) return "absent";
222
+ if (autoReviewModel === null || autoReviewModel === undefined) {
223
+ clearAutoReviewModelOverride(models, sourceModels);
224
+ return "absent";
225
+ }
226
+ const trimmed = autoReviewModel.trim();
227
+ if (!trimmed) {
228
+ clearAutoReviewModelOverride(models, sourceModels);
229
+ return "absent";
230
+ }
231
+ if (!isValidAutoReviewModel(trimmed)) {
232
+ clearAutoReviewModelOverride(models, sourceModels);
233
+ warnAutoReviewModelDiagnostic("invalid", trimmed);
234
+ return "invalid";
235
+ }
236
+ if (!configuredCatalogEntry(models, trimmed)) {
237
+ clearAutoReviewModelOverride(models, sourceModels);
238
+ warnAutoReviewModelDiagnostic("unresolved", trimmed);
239
+ return "unresolved";
240
+ }
241
+ for (const entry of models) {
242
+ if (entry && typeof entry === "object") {
243
+ stampRootAutoReviewOverride(entry, trimmed);
244
+ }
245
+ }
246
+ return "applied";
247
+ }
248
+
249
+ /** Validated provider-scoped target with both the configured spelling and catalog slug. */
250
+ interface ValidProviderReviewTarget {
251
+ configured: string;
252
+ target: string;
253
+ }
254
+
255
+ /** One provider's resolved provider-wide and per-model auto-review targets. */
256
+ interface ProviderReviewPlan {
257
+ wide?: ValidProviderReviewTarget;
258
+ perModel: Map<string, ValidProviderReviewTarget>;
259
+ }
260
+
261
+ /** Public provider namespace of a routed catalog row, when it has one. */
262
+ function catalogEntryProviderName(entry: RawEntry): string | undefined {
263
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
264
+ const slash = slug.indexOf("/");
265
+ return slash > 0 && isRoutedCatalogEntry(entry) ? slug.slice(0, slash) : undefined;
266
+ }
267
+
268
+ /** Encoded model-id segment of a routed catalog row, when it has one. */
269
+ function catalogEntryModelSegment(entry: RawEntry): string | undefined {
270
+ const slug = typeof entry.slug === "string" ? entry.slug : "";
271
+ const slash = slug.indexOf("/");
272
+ return slash > 0 ? slug.slice(slash + 1) : undefined;
273
+ }
274
+
275
+ /** Case-preserving encoded key used to match per-model override maps. */
276
+ function providerModelKey(modelId: string): string {
277
+ return canonicalAutoReviewModelKey(modelId);
278
+ }
279
+
280
+ /**
281
+ * True when another routed row of this provider already carries `alias` as its own model id.
282
+ *
283
+ * The alias API validates against whatever ids discovery has reported so far, so on a cold start an
284
+ * alias can be persisted that later turns out to name a different row. A key using it is then not
285
+ * an alternate spelling of the aliased model — it is that row's id — and must not be propagated.
286
+ */
287
+ function aliasNamesAnotherRoutedRow(models: readonly RawEntry[], provider: string, alias: string): boolean {
288
+ const encoded = encodeRoutedModelId(alias);
289
+ return models.some(entry => isRoutedCatalogEntry(entry)
290
+ && catalogEntryProviderName(entry) === provider
291
+ && catalogEntryModelSegment(entry) === encoded);
292
+ }
293
+
294
+ /** Resolve one configured target against the assembled catalog; bare values name a model of the same provider. */
295
+ function resolveProviderReviewTarget(
296
+ models: readonly RawEntry[],
297
+ provider: string,
298
+ configuredRaw: unknown,
299
+ ): { kind: "valid"; value: ValidProviderReviewTarget; foreign?: boolean } | { kind: "invalid"; configured: string } | { kind: "unresolved"; configured: string } | { kind: "absent" } {
300
+ if (typeof configuredRaw !== "string") return { kind: "absent" };
301
+ const configured = configuredRaw.trim();
302
+ if (!configured) return { kind: "absent" };
303
+ if (!isValidAutoReviewModel(configured)) return { kind: "invalid", configured };
304
+ const prefix = `${provider}/`;
305
+ let match: RawEntry | undefined;
306
+ const sameProviderCandidate = (rawModelId: string): RawEntry | undefined => models.find(entry => {
307
+ if (!isRoutedCatalogEntry(entry) || typeof entry.slug !== "string" || !entry.slug.startsWith(prefix)) return false;
308
+ const segment = catalogEntryModelSegment(entry);
309
+ return segment !== undefined && segment === encodeRoutedModelId(rawModelId);
310
+ });
311
+ // A bare selector names a model of this provider. A full selector that resolves in the
312
+ // assembled catalog already names the exact row, including a same-provider encoded slug.
313
+ if (!configured.includes("/")) {
314
+ match = sameProviderCandidate(configured);
315
+ }
316
+ match ??= configuredCatalogEntry(models, configured);
317
+ if (!match && configured.startsWith(prefix)) {
318
+ match = sameProviderCandidate(configured.slice(prefix.length));
319
+ }
320
+ if (!match) {
321
+ // A raw model id may itself contain "/" (for example zenmux moonshotai/kimi-k3).
322
+ // After the full-selector lookup misses, try that spelling as a same-provider id.
323
+ match = sameProviderCandidate(configured);
324
+ }
325
+ if (!match) return { kind: "unresolved", configured };
326
+ const target = typeof match.slug === "string" ? match.slug : configured;
327
+ // A qualified selector may name another provider's row on purpose; only a bare value that lands
328
+ // outside this provider is worth reporting.
329
+ const foreign = !configured.includes("/") && catalogEntryProviderName(match) !== provider;
330
+ return { kind: "valid", value: { configured, target }, ...(foreign ? { foreign: true } : {}) };
331
+ }
332
+
333
+ /** Build resolved per-provider plans and emit one diagnostic per bad selector. */
334
+ function buildProviderReviewPlans(
335
+ models: readonly RawEntry[],
336
+ config: Pick<OcxConfig, "providers">,
337
+ ): { plans: Map<string, ProviderReviewPlan>; failure?: "invalid" | "unresolved" } {
338
+ const plans = new Map<string, ProviderReviewPlan>();
339
+ let failure: "invalid" | "unresolved" | undefined;
340
+ const warned = new Set<string>();
341
+ const recordFailure = (kind: "invalid" | "unresolved", provider: string, configured: string): void => {
342
+ const signature = `${provider}\u0000${configured}`;
343
+ if (warned.has(signature)) return;
344
+ warned.add(signature);
345
+ warnProviderAutoReviewModelDiagnostic(kind, provider, configured);
346
+ failure ??= kind;
347
+ };
348
+ const recordForeignTarget = (provider: string, configured: string, target: string): void => {
349
+ const signature = `${provider}\u0000foreign\u0000${configured}`;
350
+ if (warned.has(signature)) return;
351
+ warned.add(signature);
352
+ warnProviderAutoReviewForeignTarget(provider, configured, target);
353
+ };
354
+ for (const [name, provider] of Object.entries(config.providers ?? {})) {
355
+ if (provider.autoReviewModel === undefined && provider.autoReviewModelOverrides === undefined) continue;
356
+ const plan: ProviderReviewPlan = { perModel: new Map() };
357
+ if (provider.autoReviewModel !== undefined) {
358
+ const resolved = resolveProviderReviewTarget(models, name, provider.autoReviewModel);
359
+ if (resolved.kind === "valid") {
360
+ plan.wide = resolved.value;
361
+ if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target);
362
+ }
363
+ else if (resolved.kind !== "absent") recordFailure(resolved.kind, name, resolved.configured);
364
+ }
365
+ if (provider.autoReviewModelOverrides !== undefined) {
366
+ for (const [modelId, rawTarget] of Object.entries(provider.autoReviewModelOverrides)) {
367
+ const resolved = resolveProviderReviewTarget(models, name, rawTarget);
368
+ if (resolved.kind === "valid") {
369
+ plan.perModel.set(providerModelKey(modelId), resolved.value);
370
+ if (resolved.foreign) recordForeignTarget(name, resolved.value.configured, resolved.value.target);
371
+ } else if (resolved.kind !== "absent") {
372
+ recordFailure(resolved.kind, name, resolved.configured);
373
+ }
374
+ }
375
+ }
376
+ // `modelAliases` publishes a second public name for a model id, and a routed row's slug always
377
+ // carries the upstream id — so accept an override key written in either spelling.
378
+ for (const [modelId, alias] of Object.entries(provider.modelAliases ?? {})) {
379
+ if (typeof alias !== "string" || !alias.trim()) continue;
380
+ if (aliasNamesAnotherRoutedRow(models, name, alias)) continue;
381
+ const idKey = providerModelKey(modelId);
382
+ const aliasKey = providerModelKey(alias);
383
+ if (idKey === aliasKey) continue;
384
+ const fromId = plan.perModel.get(idKey);
385
+ const fromAlias = plan.perModel.get(aliasKey);
386
+ if (fromId !== undefined && fromAlias === undefined) plan.perModel.set(aliasKey, fromId);
387
+ else if (fromAlias !== undefined && fromId === undefined) plan.perModel.set(idKey, fromAlias);
388
+ }
389
+ if (plan.wide !== undefined || plan.perModel.size > 0) plans.set(name, plan);
390
+ }
391
+ return { plans, failure };
392
+ }
393
+
394
+ /** Apply or clear the root selector only on rows without a provider stamp. */
395
+ function applyRootSelectorToRemaining(
396
+ models: readonly RawEntry[],
397
+ rootValue: string | null | undefined,
398
+ providerStamped: ReadonlySet<RawEntry>,
399
+ ): AutoReviewModelOverrideResult {
400
+ const clearRemaining = (): void => {
401
+ for (const entry of models) {
402
+ if (!entry || providerStamped.has(entry)) continue;
403
+ // Native rows written by releases before the root marker cannot be told apart from upstream
404
+ // values once provider stamps diverge. clearLegacyRootStamps sweeps the ones the legacy
405
+ // uniform signature still recognizes before provider plans land, because provider stamping
406
+ // destroys that signature; a catalog that no longer matches it needs a one-off manual sync.
407
+ if (isRoutedCatalogEntry(entry) || entry[AUTO_REVIEW_ROOT_MARKER] === true || rootAutoReviewStamp(entry)) clearAutoReviewOverrideValue(entry);
408
+ }
409
+ };
410
+ if (rootValue === null || rootValue === undefined) {
411
+ clearRemaining();
412
+ return "absent";
413
+ }
414
+ const trimmed = rootValue.trim();
415
+ if (!trimmed) {
416
+ clearRemaining();
417
+ return "absent";
418
+ }
419
+ if (!isValidAutoReviewModel(trimmed)) {
420
+ clearRemaining();
421
+ warnAutoReviewModelDiagnostic("invalid", trimmed);
422
+ return "invalid";
423
+ }
424
+ if (!configuredCatalogEntry(models, trimmed)) {
425
+ clearRemaining();
426
+ warnAutoReviewModelDiagnostic("unresolved", trimmed);
427
+ return "unresolved";
428
+ }
429
+ for (const entry of models) {
430
+ if (!entry || providerStamped.has(entry)) continue;
431
+ stampRootAutoReviewOverride(entry, trimmed);
432
+ }
433
+ return "applied";
434
+ }
435
+
436
+ /** Provider-aware variant: provider rows win and the root selector is the fallback. */
437
+ export function applyConfiguredAutoReviewModelOverride(
438
+ models: RawEntry[] | undefined,
439
+ rootAutoReviewModel: string | null | undefined,
440
+ config: Pick<OcxConfig, "providers">,
441
+ sourceModels: readonly RawEntry[] = [],
442
+ ): AutoReviewModelOverrideResult {
443
+ if (!models || !Array.isArray(models)) return "absent";
444
+ // Runs unconditionally because the sweep only fires on the uniform legacy signature. A resolved
445
+ // root selector restamps every row it touches below, so the call is behavior-preserving there;
446
+ // with the root absent, invalid, or unresolved those clears are final — which is the point, and
447
+ // also the limit: the legacy heuristic cannot tell a root stamp from an identical upstream value.
448
+ clearLegacyRootStamps(models, sourceModels);
449
+ const { plans, failure } = buildProviderReviewPlans(models, config);
450
+ const providerStamped = new Set<RawEntry>();
451
+ for (const entry of models) {
452
+ if (!entry || typeof entry !== "object") continue;
453
+ const provider = catalogEntryProviderName(entry);
454
+ if (!provider) continue;
455
+ const plan = plans.get(provider);
456
+ if (!plan) continue;
457
+ const modelSegment = catalogEntryModelSegment(entry);
458
+ const perModel = modelSegment === undefined ? undefined : plan.perModel.get(providerModelKey(modelSegment));
459
+ const selected = perModel ?? plan.wide;
460
+ if (!selected) continue;
461
+ stampProviderAutoReviewOverride(entry, selected.target);
462
+ providerStamped.add(entry);
463
+ }
464
+ const rootResult = applyRootSelectorToRemaining(models, rootAutoReviewModel, providerStamped);
465
+ const providerApplied = [...providerStamped].some(entry => typeof entry.auto_review_model_override === "string");
466
+ if (providerApplied) {
467
+ if (rootResult === "invalid" || rootResult === "unresolved") return rootResult;
468
+ return failure ?? "applied";
469
+ }
470
+ return failure ?? rootResult;
471
+ }
472
+
473
+ /** True when any provider row configures a provider-scoped auto-review selector. */
474
+ function configHasProviderAutoReview(config: Pick<OcxConfig, "providers">): boolean {
475
+ return Object.values(config.providers ?? {}).some(provider =>
476
+ provider.autoReviewModel !== undefined || provider.autoReviewModelOverrides !== undefined);
477
+ }
478
+
479
+ /** Apply the root Codex auto-review selector after the final catalog merge. */
480
+ export function finalizeAutoReviewModelOverride(
481
+ models: RawEntry[] | undefined,
482
+ sourceModels: readonly RawEntry[] = [],
483
+ config?: Pick<OcxConfig, "providers">,
484
+ ): AutoReviewModelOverrideResult {
485
+ if (models && sourceModels.length > 0) preserveNativeAutoReviewModelOverrides(models, sourceModels);
486
+ if (config && configHasProviderAutoReview(config)) {
487
+ return applyConfiguredAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), config, sourceModels);
488
+ }
489
+ return applyAutoReviewModelOverride(models, readConfiguredAutoReviewModel(), sourceModels);
490
+ }
491
+ /**
492
+ * Why an account-gated native model stopped being offered, but only when the answer is one the
493
+ * operator can act on.
494
+ *
495
+ * Suppression is an omission: the row is never built, so there is no catalog entry for a reason
496
+ * to ride on and no downstream consumer that could explain it later. #4212's reporter watched
497
+ * their models disappear and reasonably concluded the proxy was broken, because every surface
498
+ * that changed said nothing about the account that caused it.
499
+ *
500
+ * Returns `undefined` for the ordinary case — an account that is simply not entitled to a gated
501
+ * model. That is the default state for most installations, it is not news, and warning about it
502
+ * on every sync would bury the one case that matters. A credential the operator must repair is
503
+ * the case that matters, so that is the only one this speaks up about.
504
+ *
505
+ * Accounts are named with the durable `p`-prefixed log label, the same identifier the dashboard
506
+ * shows, never the raw pool id or the email.
507
+ */