@bitkyc08/opencodex 2.55.0 → 2.57.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.
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PoolQuotaWriter } from "./quota-types";
|
|
2
|
-
import { createHash,
|
|
2
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
CodexCredentialGenerationConflictError,
|
|
5
5
|
CodexCredentialRefreshLockTimeoutError,
|
|
@@ -39,8 +39,20 @@ import {
|
|
|
39
39
|
pickAlternateCodexAccount,
|
|
40
40
|
resolveCodexAccountForThreadDetailed,
|
|
41
41
|
type CodexAffinityDecision,
|
|
42
|
+
type CodexThreadResolution,
|
|
43
|
+
type TransientProbeGrant,
|
|
42
44
|
} from "./routing";
|
|
45
|
+
// The half-open TRANSIENT-HOLD lease (#4701). Not the quota-cooldown probe lease imported from
|
|
46
|
+
// ./routing above -- different module, different domain, and a request never holds both.
|
|
47
|
+
import { releaseTransientProbe } from "../routing/probe-lease";
|
|
43
48
|
import {
|
|
49
|
+
codexConversationIdentity,
|
|
50
|
+
recordCodexThreadLineage,
|
|
51
|
+
resolveCodexThreadLineage,
|
|
52
|
+
type CodexThreadLineage,
|
|
53
|
+
} from "./lineage";
|
|
54
|
+
import {
|
|
55
|
+
cachedDeniedCodexAccountIdsForModel,
|
|
44
56
|
entitledCodexAccountIdsForModel,
|
|
45
57
|
isDirectCallerEntitledToCodexModel,
|
|
46
58
|
resolveCodexModelEntitlements,
|
|
@@ -53,7 +65,6 @@ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainP
|
|
|
53
65
|
import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
54
66
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
55
67
|
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
56
|
-
import { retainedUtf8Bytes } from "../lib/admission";
|
|
57
68
|
import { extractAccountId, extractEmail } from "../oauth/chatgpt";
|
|
58
69
|
import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
59
70
|
import {
|
|
@@ -70,9 +81,6 @@ import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
|
70
81
|
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
|
|
71
82
|
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
|
|
72
83
|
|
|
73
|
-
const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
|
|
74
|
-
const CODEX_APP_AFFINITY_KEY = randomBytes(32);
|
|
75
|
-
|
|
76
84
|
/**
|
|
77
85
|
* A request-owned bearer cannot inspect the physical main credential for its plan, but cached
|
|
78
86
|
* WHAM usage is still valid routing evidence for the same logical main account. Score it with
|
|
@@ -88,32 +96,90 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
|
|
|
88
96
|
return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
|
|
89
97
|
}
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Every thread keys as ITSELF, never as its parent (#4546, wp8).
|
|
101
|
+
*
|
|
102
|
+
* The old rule preferred `x-codex-parent-thread-id`, so every child of one parent bound under
|
|
103
|
+
* the RAW parent id -- one shared entry, unrelated to the root's own `app:HMAC(session, thread)`
|
|
104
|
+
* binding -- and a grandchild keyed on its own parent landed on a key nobody had ever bound.
|
|
105
|
+
* A child therefore started cold while its parent was being served warm somewhere, and no
|
|
106
|
+
* child could hold a binding of its own.
|
|
107
|
+
*
|
|
108
|
+
* Now a request with a `thread-id` keys as HMAC(session ?? parent, thread). A root is
|
|
109
|
+
* unchanged, a child gets an independent key, and a request naming only a parent rides the
|
|
110
|
+
* parent's lane under HMAC(parent, parent) -- the same one-to-one lane it always had, minus
|
|
111
|
+
* the caller-supplied identifier that used to sit in Pool state. Which requests produce no
|
|
112
|
+
* key at all is unchanged. First placement for a child is what consults the family, through
|
|
113
|
+
* `recordCodexThreadLineage` below and the placement hook in ./routing.
|
|
114
|
+
*
|
|
115
|
+
* The derivation itself lives in ./lineage so a lineage record's conversation key and the key
|
|
116
|
+
* the thread actually binds under can never drift apart.
|
|
117
|
+
*/
|
|
118
|
+
export function codexPoolAffinityKey(headers: Headers, now = Date.now()): string | undefined {
|
|
119
|
+
// `now` is threaded rather than read inside because a parent-only turn resolves its key
|
|
120
|
+
// through the recorded lineage, and that record is TTL-bounded: a caller working against a
|
|
121
|
+
// fixed clock would otherwise see a live record as expired and fall back to a key the parent
|
|
122
|
+
// never bound under.
|
|
123
|
+
return codexConversationIdentity(headers, now)?.conversationKey;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What a caller needs to know to answer the Pool-state question below before auth has run. */
|
|
127
|
+
export interface CodexPoolStateEligibility {
|
|
128
|
+
/** An exact account selector from the route, i.e. `options.accountId` here. */
|
|
129
|
+
readonly accountId?: string;
|
|
130
|
+
readonly modelId?: string;
|
|
131
|
+
readonly admission?: Pick<DataPlaneAdmission, "source">;
|
|
132
|
+
/** The caller presented its own forwardable ChatGPT credential for this route. */
|
|
133
|
+
readonly requestScopedMainCredential?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The one expression both the resolution below and any preview must agree on. */
|
|
137
|
+
function poolStateEligible(
|
|
138
|
+
fixedAccountId: string | undefined,
|
|
139
|
+
requestScopedMainCredential: boolean,
|
|
140
|
+
): boolean {
|
|
141
|
+
return fixedAccountId === undefined && !requestScopedMainCredential;
|
|
96
142
|
}
|
|
97
143
|
|
|
98
144
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
145
|
+
* May this request own Pool affinity state at all?
|
|
146
|
+
*
|
|
147
|
+
* Two credentials authenticate outside the Pool: an exact account selector (including the
|
|
148
|
+
* Reserve pin) and a request-owned main bearer, which exists for one request and must never
|
|
149
|
+
* fold into durable account state. Neither may read or write a binding, so neither may read
|
|
150
|
+
* or write LINEAGE either.
|
|
151
|
+
*
|
|
152
|
+
* Exported so that a preview asks the question with the code that answers it, instead of a
|
|
153
|
+
* restatement that can drift. It drifted once already: preview read a family relation from raw
|
|
154
|
+
* request headers before this function had decided anything, so it could follow a Pool family
|
|
155
|
+
* binding while the resolution below deliberately created no affinity -- and model fallback then
|
|
156
|
+
* evaluated eligibility against an account the request would never be authenticated as.
|
|
157
|
+
*/
|
|
158
|
+
export function codexPoolStateEligible(
|
|
159
|
+
headers: Headers,
|
|
160
|
+
policy: CodexAuthPolicyConfig | undefined,
|
|
161
|
+
options: CodexPoolStateEligibility = {},
|
|
162
|
+
): boolean {
|
|
163
|
+
const reserve = requiresReserveAuthorization(policy, options.modelId, options.admission);
|
|
164
|
+
return poolStateEligible(
|
|
165
|
+
reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId,
|
|
166
|
+
options.requestScopedMainCredential === true && hasCallerCodexBearer(headers),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The lineage a PREVIEW is allowed to see: read-only, and only for a request that may hold Pool
|
|
172
|
+
* state. Recording is left to the resolution that actually binds, so a preview can never leave a
|
|
173
|
+
* record behind for a request that turned out to own no Pool state at all.
|
|
102
174
|
*/
|
|
103
|
-
export function
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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")}`;
|
|
175
|
+
export function previewCodexPoolLineage(
|
|
176
|
+
headers: Headers,
|
|
177
|
+
policy: CodexAuthPolicyConfig | undefined,
|
|
178
|
+
options: CodexPoolStateEligibility = {},
|
|
179
|
+
): CodexThreadLineage | undefined {
|
|
180
|
+
return codexPoolStateEligible(headers, policy, options)
|
|
181
|
+
? resolveCodexThreadLineage(headers)
|
|
182
|
+
: undefined;
|
|
117
183
|
}
|
|
118
184
|
|
|
119
185
|
export type CodexAuthContext =
|
|
@@ -142,6 +208,12 @@ export type CodexAuthContext =
|
|
|
142
208
|
affinityDecision?: CodexAffinityDecision;
|
|
143
209
|
/** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
|
|
144
210
|
probeQuotaScope?: CodexQuotaScope;
|
|
211
|
+
/**
|
|
212
|
+
* Set when this request is the ONE dispatch admitted to test an account held under a
|
|
213
|
+
* transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled
|
|
214
|
+
* by the request that ran it, and release it on any path that never reaches upstream.
|
|
215
|
+
*/
|
|
216
|
+
transientProbe?: TransientProbeGrant;
|
|
145
217
|
}
|
|
146
218
|
| {
|
|
147
219
|
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
|
|
@@ -162,6 +234,8 @@ export type CodexAuthContext =
|
|
|
162
234
|
probeLeaseId?: string;
|
|
163
235
|
quotaScope?: CodexQuotaScope;
|
|
164
236
|
probeQuotaScope?: CodexQuotaScope;
|
|
237
|
+
/** See `pool.transientProbe`. */
|
|
238
|
+
transientProbe?: TransientProbeGrant;
|
|
165
239
|
};
|
|
166
240
|
|
|
167
241
|
/** Probe lease carried by this context, when it holds one. */
|
|
@@ -174,11 +248,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu
|
|
|
174
248
|
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
|
|
175
249
|
}
|
|
176
250
|
|
|
251
|
+
/** The transient-hold recovery probe carried by this context, when it holds one (#4701). */
|
|
252
|
+
export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined {
|
|
253
|
+
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
177
256
|
/**
|
|
178
257
|
* Hand back a probe lease for a request that will not reach upstream. Safe to
|
|
179
258
|
* call with a context that holds no lease.
|
|
259
|
+
*
|
|
260
|
+
* BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold
|
|
261
|
+
* probe, and every one of the ~30 call sites that already hands back the first is a path where
|
|
262
|
+
* the second would leak too. Releasing them together is what makes those sites correct for the
|
|
263
|
+
* new lease without re-deriving the discard set by hand -- the failure mode being avoided is a
|
|
264
|
+
* held account nobody may probe because the request that held the trial went away quietly.
|
|
180
265
|
*/
|
|
181
266
|
export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
|
|
267
|
+
const transientProbe = codexTransientProbeGrant(ctx);
|
|
268
|
+
if (transientProbe) releaseTransientProbe(transientProbe.lease);
|
|
182
269
|
const leaseId = codexProbeLeaseId(ctx);
|
|
183
270
|
if (!ctx || ctx.kind === "main" || !leaseId) return;
|
|
184
271
|
if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
|
|
@@ -364,6 +451,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE
|
|
|
364
451
|
}
|
|
365
452
|
}
|
|
366
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Every account bound to this conversation is held after upstream failures, the recovery
|
|
456
|
+
* budget for this window is spent, and there is no detour left -- so this request is refused
|
|
457
|
+
* BEFORE any upstream I/O (#4701).
|
|
458
|
+
*
|
|
459
|
+
* This is not a quota cooldown, and the message below says so. It subclasses
|
|
460
|
+
* {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly
|
|
461
|
+
* one representation in this codebase, and roughly a dozen transports already map it to a 429
|
|
462
|
+
* with `Retry-After` and treat it as an expected terminal answer rather than a credential
|
|
463
|
+
* fault. Introducing a parallel type would mean either re-deriving that handling in every one
|
|
464
|
+
* of them or silently falling through to a 500 in the ones that were missed.
|
|
465
|
+
*
|
|
466
|
+
* What must NOT be inherited is the quota wording -- "cooling down", `ocx account
|
|
467
|
+
* clear-cooldown` -- because none of it describes a 5xx hold and following it would do
|
|
468
|
+
* nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim,
|
|
469
|
+
* the same escape hatch {@link CodexMainAccountHardLockError} and
|
|
470
|
+
* {@link CodexReserveUnavailableError} already use.
|
|
471
|
+
*
|
|
472
|
+
* `cooldownUntil` carries the limiter's own change point, which is strictly in the future:
|
|
473
|
+
* either the moment the held account may next be probed or the moment the recovery window
|
|
474
|
+
* moves, whichever is later. A refusal that answered `now` would busy-loop the caller into
|
|
475
|
+
* the same load it just declined.
|
|
476
|
+
*/
|
|
477
|
+
export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
|
|
478
|
+
/** The sibling still remembered for this thread, when one exists but is itself unusable. */
|
|
479
|
+
readonly detourAccountId?: string;
|
|
480
|
+
|
|
481
|
+
constructor(accountId: string, retryAt: number, detourAccountId?: string) {
|
|
482
|
+
super(accountId, retryAt);
|
|
483
|
+
this.name = "CodexRecoveryWithheldError";
|
|
484
|
+
this.detourAccountId = detourAccountId;
|
|
485
|
+
this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream`
|
|
486
|
+
+ ` failures and the pool's recovery budget for this window is spent, so nothing was sent`
|
|
487
|
+
+ ` upstream. Retry after ${new Date(retryAt).toISOString()}.`
|
|
488
|
+
+ " This clears on its own as the account recovers; no cooldown to lift and no account to switch.";
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
367
492
|
export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
|
|
368
493
|
"codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
|
|
369
494
|
>>;
|
|
@@ -574,7 +699,12 @@ export function cooldownAccountLabel(accountId: string): string {
|
|
|
574
699
|
* injected `openai_base_url` in config.toml.
|
|
575
700
|
*/
|
|
576
701
|
export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
|
|
577
|
-
if (err instanceof CodexMainAccountHardLockError
|
|
702
|
+
if (err instanceof CodexMainAccountHardLockError
|
|
703
|
+
|| err instanceof CodexReserveUnavailableError
|
|
704
|
+
// A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
|
|
705
|
+
// one, and the quota recovery advice below would send the operator after a cooldown that
|
|
706
|
+
// does not exist (#4701).
|
|
707
|
+
|| err instanceof CodexRecoveryWithheldError) return err.message;
|
|
578
708
|
const until = new Date(err.cooldownUntil).toISOString();
|
|
579
709
|
const scopeLabels: Record<CodexQuotaScope, string> = {
|
|
580
710
|
shared: "shared native quota", reserve: "Reserve quota",
|
|
@@ -657,6 +787,11 @@ export interface ResolveCodexAuthContextOptions {
|
|
|
657
787
|
requestScopedMainCredential?: boolean;
|
|
658
788
|
/** Test seam for a Direct request's own forwarded ChatGPT credential. */
|
|
659
789
|
isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
|
|
790
|
+
/**
|
|
791
|
+
* This request's conversation carries live uploaded-file references (#4778). Retains the bound
|
|
792
|
+
* account across a VOLUNTARY quota move; involuntary release is untouched.
|
|
793
|
+
*/
|
|
794
|
+
retainAccountForUploadedFiles?: boolean;
|
|
660
795
|
}
|
|
661
796
|
|
|
662
797
|
export interface CodexAccountSelectionAdmission {
|
|
@@ -798,12 +933,27 @@ export async function resolveCodexAuthContext(
|
|
|
798
933
|
// A caller bearer can still accompany a request that selects a configured Pool account. Do not
|
|
799
934
|
// let that request read, delete, or create a file-main affinity binding while deciding whether a
|
|
800
935
|
// stored account is available; only the stored credential selected below may own Pool state.
|
|
801
|
-
const affinityKey = fixedAccountId
|
|
936
|
+
const affinityKey = poolStateEligible(fixedAccountId, requestScopedMainCredential)
|
|
802
937
|
? codexPoolAffinityKey(headers)
|
|
803
938
|
: undefined;
|
|
939
|
+
// The thread's family relation, recorded under the same condition as the key itself. A
|
|
940
|
+
// first-placing child consults it; a request-owned or fixed credential never enters Pool
|
|
941
|
+
// state, so it never enters lineage either.
|
|
942
|
+
const lineage = affinityKey !== undefined
|
|
943
|
+
? recordCodexThreadLineage(headers)
|
|
944
|
+
: undefined;
|
|
804
945
|
// Why this request is on this account, carried to the request log so a move reads as an event
|
|
805
946
|
// instead of something inferred from account labels across lines (#4546).
|
|
806
947
|
let affinityDecision: CodexAffinityDecision | undefined;
|
|
948
|
+
// The half-open trial this request was granted, if it is the one allowed to test a held
|
|
949
|
+
// account. Declared out here because the release paths below and the returned context are on
|
|
950
|
+
// opposite sides of several throws (#4701).
|
|
951
|
+
let transientProbe: TransientProbeGrant | undefined;
|
|
952
|
+
const releaseTransientProbeGrant = (): void => {
|
|
953
|
+
if (!transientProbe) return;
|
|
954
|
+
releaseTransientProbe(transientProbe.lease);
|
|
955
|
+
transientProbe = undefined;
|
|
956
|
+
};
|
|
807
957
|
// Retained startup recovery makes the physical main identity ineligible. Routing
|
|
808
958
|
// can still preserve service by selecting a healthy configured pool account. A
|
|
809
959
|
// request-owned bearer likewise cannot inspect or reconcile file-main state.
|
|
@@ -834,6 +984,23 @@ export async function resolveCodexAuthContext(
|
|
|
834
984
|
const modelEligibleAccountIds = entitledAccountIds
|
|
835
985
|
? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
|
|
836
986
|
: undefined;
|
|
987
|
+
// #4768: the flagships stay visible and never fail closed, so this is evidence routing may
|
|
988
|
+
// ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has
|
|
989
|
+
// already gathered -- no upstream fetch joins the request path for the most commonly
|
|
990
|
+
// requested models in the product -- and passed to selection as a preference that is dropped
|
|
991
|
+
// whenever honouring it would leave no candidate.
|
|
992
|
+
//
|
|
993
|
+
// Under the SAME exclusion the entitlement snapshot above uses. The reader validates each
|
|
994
|
+
// cached roster against the account's current credential, and for native main that is a
|
|
995
|
+
// synchronous read of the physical stored token -- exactly what this request is forbidden to
|
|
996
|
+
// touch while a profile switch drains it or while it is served by a request-owned credential.
|
|
997
|
+
// Excluding main here costs nothing: the preference is an ordering hint, so main becomes
|
|
998
|
+
// unknown rather than denied, and unknown leaves selection exactly as it was.
|
|
999
|
+
const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(
|
|
1000
|
+
options.modelId,
|
|
1001
|
+
undefined,
|
|
1002
|
+
{ excludeAccountIds },
|
|
1003
|
+
);
|
|
837
1004
|
const selectionOptions = {
|
|
838
1005
|
// Temporary switch drain keeps the candidate until the atomic claim rejects
|
|
839
1006
|
// it. Retained recovery makes main wholly ineligible so pool routing continues.
|
|
@@ -845,13 +1012,21 @@ export async function resolveCodexAuthContext(
|
|
|
845
1012
|
? () => preserveRequestOwnedMainPin
|
|
846
1013
|
: options.isMainAccountTokenLive,
|
|
847
1014
|
modelEligibleAccountIds,
|
|
1015
|
+
deniedModelAccountIds,
|
|
1016
|
+
// Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
|
|
1017
|
+
// conversation's attachments say nothing about where unrelated threads should be served.
|
|
1018
|
+
retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
|
|
848
1019
|
};
|
|
849
1020
|
// A pre-drain selector reserves the native identity while reconciliation and
|
|
850
1021
|
// routing inspect it. Selectors arriving after the fence skip reconciliation
|
|
851
1022
|
// and may still route to non-main pool accounts without touching switch state.
|
|
852
1023
|
if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
|
|
853
1024
|
if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
|
|
854
|
-
|
|
1025
|
+
// Annotated, not inferred. The two literals below carry neither `affinity` nor
|
|
1026
|
+
// `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to
|
|
1027
|
+
// `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to
|
|
1028
|
+
// the resolver's own union is what lets the reads below stay total.
|
|
1029
|
+
const resolution: CodexThreadResolution = fixedAccountId !== undefined
|
|
855
1030
|
? { status: "selected" as const, accountId: fixedAccountId }
|
|
856
1031
|
: options.excludeAccountId
|
|
857
1032
|
? (() => {
|
|
@@ -873,10 +1048,20 @@ export async function resolveCodexAuthContext(
|
|
|
873
1048
|
quotaScope,
|
|
874
1049
|
selectionOptions,
|
|
875
1050
|
options.modelId,
|
|
1051
|
+
lineage,
|
|
876
1052
|
);
|
|
877
1053
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
1054
|
+
// THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is
|
|
1055
|
+
// left -- so this request must not reach upstream at all. Returning the held account here
|
|
1056
|
+
// is what #4701 is about: under a provider-wide 503 that is every bound request piling
|
|
1057
|
+
// onto an account already known to be failing. Thrown before any credential is read, so
|
|
1058
|
+
// nothing is sent and nothing is spent.
|
|
1059
|
+
if (resolution.status === "withheld") {
|
|
1060
|
+
throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId);
|
|
1061
|
+
}
|
|
878
1062
|
const selected = resolution.status === "selected" ? resolution.accountId : null;
|
|
879
|
-
affinityDecision =
|
|
1063
|
+
affinityDecision = resolution.affinity;
|
|
1064
|
+
transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
|
|
880
1065
|
if (!selected) {
|
|
881
1066
|
// A retry that excluded a failed Pool account may still use the validated caller-owned
|
|
882
1067
|
// main credential. Treating every exclusion as if main itself had failed strands a healthy
|
|
@@ -958,12 +1143,25 @@ export async function resolveCodexAuthContext(
|
|
|
958
1143
|
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
|
|
959
1144
|
}
|
|
960
1145
|
}
|
|
1146
|
+
} catch (cause) {
|
|
1147
|
+
// Selection granted a trial and then a later policy check refused the account. The trial
|
|
1148
|
+
// never runs, so hand it back instead of leaving the held account unprobeable until its
|
|
1149
|
+
// deadline lapses (#4701).
|
|
1150
|
+
releaseTransientProbeGrant();
|
|
1151
|
+
throw cause;
|
|
961
1152
|
} finally {
|
|
962
1153
|
selectionAdmission?.release();
|
|
963
1154
|
}
|
|
964
1155
|
// Legacy selectors may retain an unusable account for actionable errors. A
|
|
965
1156
|
// deferred credential must never become request auth through that fallback.
|
|
966
|
-
|
|
1157
|
+
try {
|
|
1158
|
+
assertCodexAccountValidationReady(accountId);
|
|
1159
|
+
} catch (cause) {
|
|
1160
|
+
// Nothing will reach upstream, so give the trial back instead of leaving the held account
|
|
1161
|
+
// unprobeable until the lease deadline lapses (#4701).
|
|
1162
|
+
releaseTransientProbeGrant();
|
|
1163
|
+
throw cause;
|
|
1164
|
+
}
|
|
967
1165
|
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
968
1166
|
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
969
1167
|
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
@@ -982,6 +1180,13 @@ export async function resolveCodexAuthContext(
|
|
|
982
1180
|
// a literal Retry-After reads very differently to a user than a reset-derived guess.
|
|
983
1181
|
const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
|
|
984
1182
|
const cooldownUntil = cooldown?.cooldownUntil;
|
|
1183
|
+
// A transient-hold trial and a quota cooldown cannot both describe this account:
|
|
1184
|
+
// `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying
|
|
1185
|
+
// quota health, so the cooldown branch below is unreachable while a trial is held. That is
|
|
1186
|
+
// also why no request pays two recovery permits for one send. The release is defensive --
|
|
1187
|
+
// should that invariant ever move, the trial is handed back rather than stranded behind a
|
|
1188
|
+
// refusal that belongs to the other domain.
|
|
1189
|
+
if (cooldownUntil && transientProbe) releaseTransientProbeGrant();
|
|
985
1190
|
// A cooled-down account never sends traffic, so upstream recovery can never be
|
|
986
1191
|
// observed and the cooldown outlives the real limit. Admit one probe per
|
|
987
1192
|
// interval; its outcome decides whether the cooldown ends (#433).
|
|
@@ -1022,6 +1227,7 @@ export async function resolveCodexAuthContext(
|
|
|
1022
1227
|
if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
|
|
1023
1228
|
assertMainAccountPolicy(policy);
|
|
1024
1229
|
} catch (cause) {
|
|
1230
|
+
releaseTransientProbeGrant();
|
|
1025
1231
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1026
1232
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1027
1233
|
if (cause instanceof CodexMainAccountHardLockError) throw cause;
|
|
@@ -1032,15 +1238,23 @@ export async function resolveCodexAuthContext(
|
|
|
1032
1238
|
}
|
|
1033
1239
|
if (!token) {
|
|
1034
1240
|
// Nothing will reach upstream, so give the probe back instead of burning it.
|
|
1241
|
+
releaseTransientProbeGrant();
|
|
1035
1242
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1036
1243
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1037
1244
|
throw new CodexPoolAuthenticationError(
|
|
1038
1245
|
fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
|
|
1039
1246
|
);
|
|
1040
1247
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1248
|
+
let reserveAuthorization: MainReserveAuthorization | undefined;
|
|
1249
|
+
try {
|
|
1250
|
+
reserveAuthorization = reserve
|
|
1251
|
+
? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
|
|
1252
|
+
: undefined;
|
|
1253
|
+
} catch (cause) {
|
|
1254
|
+
// A Reserve refusal ends the request here, so the trial it was holding never runs.
|
|
1255
|
+
releaseTransientProbeGrant();
|
|
1256
|
+
throw cause;
|
|
1257
|
+
}
|
|
1044
1258
|
return {
|
|
1045
1259
|
kind: "main-pool",
|
|
1046
1260
|
accountId,
|
|
@@ -1054,6 +1268,7 @@ export async function resolveCodexAuthContext(
|
|
|
1054
1268
|
...(quotaScope ? { quotaScope } : {}),
|
|
1055
1269
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1056
1270
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1271
|
+
...(transientProbe ? { transientProbe } : {}),
|
|
1057
1272
|
};
|
|
1058
1273
|
}
|
|
1059
1274
|
|
|
@@ -1074,8 +1289,10 @@ export async function resolveCodexAuthContext(
|
|
|
1074
1289
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1075
1290
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1076
1291
|
...(affinityDecision ? { affinityDecision } : {}),
|
|
1292
|
+
...(transientProbe ? { transientProbe } : {}),
|
|
1077
1293
|
};
|
|
1078
1294
|
} catch (cause) {
|
|
1295
|
+
releaseTransientProbeGrant();
|
|
1079
1296
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1080
1297
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1081
1298
|
if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
@@ -34,7 +34,7 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
import { catalogModelSlug } from "./parsing";
|
|
37
|
-
import type { CatalogModel } from "./parsing";
|
|
37
|
+
import type { CatalogModel, RawEntry } from "./parsing";
|
|
38
38
|
|
|
39
39
|
export const openAiApiCollisionWarnings = new Set<string>();
|
|
40
40
|
|
|
@@ -222,6 +222,85 @@ export function safeCatalogWarningLabel(value: string): string {
|
|
|
222
222
|
.slice(0, 200);
|
|
223
223
|
}
|
|
224
224
|
|
|
225
|
+
/**
|
|
226
|
+
* Keep the first row of each slug and drop the rest (#4730).
|
|
227
|
+
*
|
|
228
|
+
* First-win is the only answer that agrees with the ordering already decided upstream: the merge
|
|
229
|
+
* ranks rows, so its first occurrence is the row it chose. Distinct slugs are never touched — an
|
|
230
|
+
* alias row and the canonical routed row of the same provider model are two different public names
|
|
231
|
+
* and both survive — and a row without a string slug passes through untouched.
|
|
232
|
+
*/
|
|
233
|
+
export function dedupeCatalogEntriesBySlug(models: RawEntry[]): RawEntry[] {
|
|
234
|
+
const seen = new Set<string>();
|
|
235
|
+
const out: RawEntry[] = [];
|
|
236
|
+
for (const entry of models) {
|
|
237
|
+
if (typeof entry.slug !== "string") {
|
|
238
|
+
out.push(entry);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (seen.has(entry.slug)) continue;
|
|
242
|
+
seen.add(entry.slug);
|
|
243
|
+
out.push(entry);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Every slug this proxy writes into the Codex catalog must appear exactly once (#4730).
|
|
250
|
+
*
|
|
251
|
+
* The cost of breaking it is the whole file: a slug-unique validating consumer refuses the catalog
|
|
252
|
+
* outright, so one duplicated row takes every model with it. A 2.56.0 report carried 507 rows for
|
|
253
|
+
* 72 unique slugs, every duplicate byte-identical, and Codex rejected the file as `source-invalid`.
|
|
254
|
+
*
|
|
255
|
+
* This is a write-boundary invariant rather than a repair of one producer, and that distinction is
|
|
256
|
+
* deliberate: the reported catalog is evidence that some emit path can double a row, but nothing in
|
|
257
|
+
* this tree has been shown to be that path, and a guard that only covered the producer someone
|
|
258
|
+
* guessed at would leave the file corruptible by the next one. Both writers that serialize a merged
|
|
259
|
+
* catalog call this as their LAST mutation — `writeRetainedCatalogSync` and the management
|
|
260
|
+
* convergence commit — so uniqueness holds for the exact bytes that land on disk.
|
|
261
|
+
*
|
|
262
|
+
* Ordering is load-bearing. Running the guard before the effort clamp would be unsound:
|
|
263
|
+
* `clampCatalogModelsToObservedCodexSupport` splices whole rows out when an exact-reserve ladder
|
|
264
|
+
* clamps empty, so dropping a later same-slug row first can leave the slug with no row at all once
|
|
265
|
+
* the surviving one is spliced.
|
|
266
|
+
*
|
|
267
|
+
* @param models - The finished row list, already clamped and finalized.
|
|
268
|
+
* @param warn - Whether to report on `console.warn`. The convergence path merges under
|
|
269
|
+
* `warningPolicy: "suppress"` and stays silent for the same reason.
|
|
270
|
+
* @returns The original array when it was already unique, so an unchanged catalog stays a no-op
|
|
271
|
+
* write; otherwise a first-win copy.
|
|
272
|
+
*/
|
|
273
|
+
export function enforceCatalogSlugUniqueness(models: RawEntry[], warn: boolean): RawEntry[] {
|
|
274
|
+
const deduped = dedupeCatalogEntriesBySlug(models);
|
|
275
|
+
if (deduped.length === models.length) return models;
|
|
276
|
+
if (warn) {
|
|
277
|
+
// A dropped row that differs from the kept one means two emit paths disagree about the same
|
|
278
|
+
// slug's content. First-win still stands, but the operator needs to see WHICH slugs diverged
|
|
279
|
+
// instead of silently losing data. The baseline is the row the dedupe actually keeps — the
|
|
280
|
+
// FIRST occurrence — so the reported divergence is measured against what lands on disk.
|
|
281
|
+
const keptBySlug = new Map<string, RawEntry>();
|
|
282
|
+
for (const entry of models) {
|
|
283
|
+
if (typeof entry.slug !== "string" || keptBySlug.has(entry.slug)) continue;
|
|
284
|
+
keptBySlug.set(entry.slug, entry);
|
|
285
|
+
}
|
|
286
|
+
const divergentSlugs = new Set<string>();
|
|
287
|
+
for (const entry of models) {
|
|
288
|
+
if (typeof entry.slug !== "string") continue;
|
|
289
|
+
const kept = keptBySlug.get(entry.slug);
|
|
290
|
+
if (kept && kept !== entry && JSON.stringify(kept) !== JSON.stringify(entry)) {
|
|
291
|
+
divergentSlugs.add(entry.slug);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const divergentNote = divergentSlugs.size > 0
|
|
295
|
+
? `; divergent content on: ${[...divergentSlugs].slice(0, 5).map(safeCatalogWarningLabel).join(", ")}${divergentSlugs.size > 5 ? ", …" : ""}`
|
|
296
|
+
: "";
|
|
297
|
+
console.warn(
|
|
298
|
+
`[opencodex] catalog sync dropped ${models.length - deduped.length} duplicate slug row(s), keeping the first occurrence of each slug (#4730)${divergentNote}.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
return deduped;
|
|
302
|
+
}
|
|
303
|
+
|
|
225
304
|
export function comboCatalogWarningSignature(
|
|
226
305
|
combo: NormalizedComboConfig,
|
|
227
306
|
members: readonly CatalogModel[],
|