@bitkyc08/opencodex 2.56.0 → 2.58.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-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
- package/src/adapters/command-code.ts +12 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/google.ts +7 -7
- 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/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +62 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +8 -2
- package/src/bridge/sse.ts +20 -2
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +32 -4
- 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/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +92 -7
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +74 -5
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +199 -15
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- 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/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +231 -32
- package/src/codex/inject.ts +12 -16
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- 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/atomic-write.ts +83 -8
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +272 -180
- package/src/images/loop.ts +1 -1
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -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 +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/derive.ts +6 -0
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +35 -7
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +59 -28
- package/src/providers/registry/model-seeds.ts +71 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +54 -3
- package/src/server/index.ts +5 -5
- 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/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- 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/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +129 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +30 -6
- package/src/server/responses/passthrough-dispatch.ts +61 -11
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +173 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- 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 +8 -1
- package/src/types/provider.ts +13 -0
- 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 +1 -1
- 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
|
@@ -39,7 +39,12 @@ 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 {
|
|
44
49
|
codexConversationIdentity,
|
|
45
50
|
recordCodexThreadLineage,
|
|
@@ -47,6 +52,7 @@ import {
|
|
|
47
52
|
type CodexThreadLineage,
|
|
48
53
|
} from "./lineage";
|
|
49
54
|
import {
|
|
55
|
+
cachedDeniedCodexAccountIdsForModel,
|
|
50
56
|
entitledCodexAccountIdsForModel,
|
|
51
57
|
isDirectCallerEntitledToCodexModel,
|
|
52
58
|
resolveCodexModelEntitlements,
|
|
@@ -90,6 +96,44 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
|
|
|
90
96
|
return usage >= CODEX_UNKNOWN_USAGE_SCORE || usage < threshold;
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Whether a request carrying its OWN main credential still serves on main because the operator
|
|
101
|
+
* manually pinned it (#3166), split from the surrounding resolution so request preview can ask
|
|
102
|
+
* the identical question (#4850).
|
|
103
|
+
*
|
|
104
|
+
* Exported for exactly one reason: two copies of this fence is how #4850 happened. Final
|
|
105
|
+
* authentication honoured the ownership boundary while request preview, computing its fence from
|
|
106
|
+
* recovery and drain state alone, still handed pool eligibility the default liveness probe and
|
|
107
|
+
* opened the physical `auth.json` twice per spawn. A predicate one caller can forget is a
|
|
108
|
+
* predicate the other caller will eventually disagree with.
|
|
109
|
+
*
|
|
110
|
+
* Read-free by construction, which is what makes it usable on the fenced side. Every input is
|
|
111
|
+
* config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota
|
|
112
|
+
* score, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
|
|
113
|
+
* credential record in `main-account-cache.ts`. Nothing here opens a file.
|
|
114
|
+
*
|
|
115
|
+
* `candidate` is the pin before the hard-lock question, because the caller still owes the
|
|
116
|
+
* pending-binding check that only final authentication can fail closed on.
|
|
117
|
+
*/
|
|
118
|
+
export function requestOwnedMainPinState(
|
|
119
|
+
headers: Headers,
|
|
120
|
+
config: OcxConfig,
|
|
121
|
+
policy: CodexAuthPolicyConfig,
|
|
122
|
+
requestScopedMainCredential: boolean,
|
|
123
|
+
fixedAccountId: string | undefined,
|
|
124
|
+
): { candidate: boolean; preserve: boolean } {
|
|
125
|
+
const candidate = requestScopedMainCredential
|
|
126
|
+
&& fixedAccountId === undefined
|
|
127
|
+
&& config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
|
|
128
|
+
&& isEffectiveCodexAccountPinned(config)
|
|
129
|
+
&& !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
|
|
130
|
+
&& requestOwnedMainPinHasQuotaHeadroom(config);
|
|
131
|
+
return {
|
|
132
|
+
candidate,
|
|
133
|
+
preserve: candidate && !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
/**
|
|
94
138
|
* Every thread keys as ITSELF, never as its parent (#4546, wp8).
|
|
95
139
|
*
|
|
@@ -202,6 +246,12 @@ export type CodexAuthContext =
|
|
|
202
246
|
affinityDecision?: CodexAffinityDecision;
|
|
203
247
|
/** Scope that owns `probeLeaseId`, when it is a scoped recovery probe. */
|
|
204
248
|
probeQuotaScope?: CodexQuotaScope;
|
|
249
|
+
/**
|
|
250
|
+
* Set when this request is the ONE dispatch admitted to test an account held under a
|
|
251
|
+
* transient 5xx hold (#4701). Echo it into the upstream outcome so the trial is settled
|
|
252
|
+
* by the request that ran it, and release it on any path that never reaches upstream.
|
|
253
|
+
*/
|
|
254
|
+
transientProbe?: TransientProbeGrant;
|
|
205
255
|
}
|
|
206
256
|
| {
|
|
207
257
|
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
|
|
@@ -222,6 +272,8 @@ export type CodexAuthContext =
|
|
|
222
272
|
probeLeaseId?: string;
|
|
223
273
|
quotaScope?: CodexQuotaScope;
|
|
224
274
|
probeQuotaScope?: CodexQuotaScope;
|
|
275
|
+
/** See `pool.transientProbe`. */
|
|
276
|
+
transientProbe?: TransientProbeGrant;
|
|
225
277
|
};
|
|
226
278
|
|
|
227
279
|
/** Probe lease carried by this context, when it holds one. */
|
|
@@ -234,11 +286,24 @@ export function codexProbeQuotaScope(ctx: CodexAuthContext | undefined): CodexQu
|
|
|
234
286
|
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeQuotaScope : undefined;
|
|
235
287
|
}
|
|
236
288
|
|
|
289
|
+
/** The transient-hold recovery probe carried by this context, when it holds one (#4701). */
|
|
290
|
+
export function codexTransientProbeGrant(ctx: CodexAuthContext | undefined): TransientProbeGrant | undefined {
|
|
291
|
+
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.transientProbe : undefined;
|
|
292
|
+
}
|
|
293
|
+
|
|
237
294
|
/**
|
|
238
295
|
* Hand back a probe lease for a request that will not reach upstream. Safe to
|
|
239
296
|
* call with a context that holds no lease.
|
|
297
|
+
*
|
|
298
|
+
* BOTH leases, deliberately. A context can carry the quota-cooldown probe or the transient-hold
|
|
299
|
+
* probe, and every one of the ~30 call sites that already hands back the first is a path where
|
|
300
|
+
* the second would leak too. Releasing them together is what makes those sites correct for the
|
|
301
|
+
* new lease without re-deriving the discard set by hand -- the failure mode being avoided is a
|
|
302
|
+
* held account nobody may probe because the request that held the trial went away quietly.
|
|
240
303
|
*/
|
|
241
304
|
export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
|
|
305
|
+
const transientProbe = codexTransientProbeGrant(ctx);
|
|
306
|
+
if (transientProbe) releaseTransientProbe(transientProbe.lease);
|
|
242
307
|
const leaseId = codexProbeLeaseId(ctx);
|
|
243
308
|
if (!ctx || ctx.kind === "main" || !leaseId) return;
|
|
244
309
|
if (ctx.probeQuotaScope) releaseCodexQuotaScopeProbeLease(ctx.accountId!, ctx.probeQuotaScope, leaseId);
|
|
@@ -424,6 +489,44 @@ export class CodexReserveHelperUnsupportedError extends CodexReserveUnavailableE
|
|
|
424
489
|
}
|
|
425
490
|
}
|
|
426
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Every account bound to this conversation is held after upstream failures, the recovery
|
|
494
|
+
* budget for this window is spent, and there is no detour left -- so this request is refused
|
|
495
|
+
* BEFORE any upstream I/O (#4701).
|
|
496
|
+
*
|
|
497
|
+
* This is not a quota cooldown, and the message below says so. It subclasses
|
|
498
|
+
* {@link CodexAccountCooldownError} for one reason: the deadline-carrying refusal has exactly
|
|
499
|
+
* one representation in this codebase, and roughly a dozen transports already map it to a 429
|
|
500
|
+
* with `Retry-After` and treat it as an expected terminal answer rather than a credential
|
|
501
|
+
* fault. Introducing a parallel type would mean either re-deriving that handling in every one
|
|
502
|
+
* of them or silently falling through to a 500 in the ones that were missed.
|
|
503
|
+
*
|
|
504
|
+
* What must NOT be inherited is the quota wording -- "cooling down", `ocx account
|
|
505
|
+
* clear-cooldown` -- because none of it describes a 5xx hold and following it would do
|
|
506
|
+
* nothing. {@link cooldownErrorMessage} therefore returns this class's own message verbatim,
|
|
507
|
+
* the same escape hatch {@link CodexMainAccountHardLockError} and
|
|
508
|
+
* {@link CodexReserveUnavailableError} already use.
|
|
509
|
+
*
|
|
510
|
+
* `cooldownUntil` carries the limiter's own change point, which is strictly in the future:
|
|
511
|
+
* either the moment the held account may next be probed or the moment the recovery window
|
|
512
|
+
* moves, whichever is later. A refusal that answered `now` would busy-loop the caller into
|
|
513
|
+
* the same load it just declined.
|
|
514
|
+
*/
|
|
515
|
+
export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
|
|
516
|
+
/** The sibling still remembered for this thread, when one exists but is itself unusable. */
|
|
517
|
+
readonly detourAccountId?: string;
|
|
518
|
+
|
|
519
|
+
constructor(accountId: string, retryAt: number, detourAccountId?: string) {
|
|
520
|
+
super(accountId, retryAt);
|
|
521
|
+
this.name = "CodexRecoveryWithheldError";
|
|
522
|
+
this.detourAccountId = detourAccountId;
|
|
523
|
+
this.message = `Codex account (${cooldownAccountLabel(accountId)}) is held after repeated upstream`
|
|
524
|
+
+ ` failures and the pool's recovery budget for this window is spent, so nothing was sent`
|
|
525
|
+
+ ` upstream. Retry after ${new Date(retryAt).toISOString()}.`
|
|
526
|
+
+ " This clears on its own as the account recovers; no cooldown to lift and no account to switch.";
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
427
530
|
export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
|
|
428
531
|
"codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
|
|
429
532
|
>>;
|
|
@@ -634,7 +737,12 @@ export function cooldownAccountLabel(accountId: string): string {
|
|
|
634
737
|
* injected `openai_base_url` in config.toml.
|
|
635
738
|
*/
|
|
636
739
|
export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
|
|
637
|
-
if (err instanceof CodexMainAccountHardLockError
|
|
740
|
+
if (err instanceof CodexMainAccountHardLockError
|
|
741
|
+
|| err instanceof CodexReserveUnavailableError
|
|
742
|
+
// A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
|
|
743
|
+
// one, and the quota recovery advice below would send the operator after a cooldown that
|
|
744
|
+
// does not exist (#4701).
|
|
745
|
+
|| err instanceof CodexRecoveryWithheldError) return err.message;
|
|
638
746
|
const until = new Date(err.cooldownUntil).toISOString();
|
|
639
747
|
const scopeLabels: Record<CodexQuotaScope, string> = {
|
|
640
748
|
shared: "shared native quota", reserve: "Reserve quota",
|
|
@@ -717,6 +825,11 @@ export interface ResolveCodexAuthContextOptions {
|
|
|
717
825
|
requestScopedMainCredential?: boolean;
|
|
718
826
|
/** Test seam for a Direct request's own forwarded ChatGPT credential. */
|
|
719
827
|
isDirectCallerEntitledToCodexModel?: (headers: Headers, modelId: string) => Promise<boolean>;
|
|
828
|
+
/**
|
|
829
|
+
* This request's conversation carries live uploaded-file references (#4778). Retains the bound
|
|
830
|
+
* account across a VOLUNTARY quota move; involuntary release is untouched.
|
|
831
|
+
*/
|
|
832
|
+
retainAccountForUploadedFiles?: boolean;
|
|
720
833
|
}
|
|
721
834
|
|
|
722
835
|
export interface CodexAccountSelectionAdmission {
|
|
@@ -741,19 +854,15 @@ export async function resolveCodexAuthContext(
|
|
|
741
854
|
throw new CodexReserveUnavailableError();
|
|
742
855
|
}
|
|
743
856
|
const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
&& !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
|
|
749
|
-
&& requestOwnedMainPinHasQuotaHeadroom(config);
|
|
857
|
+
const {
|
|
858
|
+
candidate: requestOwnedMainPinCandidate,
|
|
859
|
+
preserve: preserveRequestOwnedMainPin,
|
|
860
|
+
} = requestOwnedMainPinState(headers, config, policy, requestScopedMainCredential, fixedAccountId);
|
|
750
861
|
// During an owned startup, equality cannot be established until recovery and the
|
|
751
862
|
// memory-only policy binding finish. This read-only fence never probes a foreign home.
|
|
752
863
|
if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
|
|
753
864
|
throw new CodexMainProfileDrainingError();
|
|
754
865
|
}
|
|
755
|
-
const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
|
|
756
|
-
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
|
|
757
866
|
if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
|
|
758
867
|
throw new Error("Codex auth context cannot select and exclude an account simultaneously");
|
|
759
868
|
}
|
|
@@ -870,6 +979,15 @@ export async function resolveCodexAuthContext(
|
|
|
870
979
|
// Why this request is on this account, carried to the request log so a move reads as an event
|
|
871
980
|
// instead of something inferred from account labels across lines (#4546).
|
|
872
981
|
let affinityDecision: CodexAffinityDecision | undefined;
|
|
982
|
+
// The half-open trial this request was granted, if it is the one allowed to test a held
|
|
983
|
+
// account. Declared out here because the release paths below and the returned context are on
|
|
984
|
+
// opposite sides of several throws (#4701).
|
|
985
|
+
let transientProbe: TransientProbeGrant | undefined;
|
|
986
|
+
const releaseTransientProbeGrant = (): void => {
|
|
987
|
+
if (!transientProbe) return;
|
|
988
|
+
releaseTransientProbe(transientProbe.lease);
|
|
989
|
+
transientProbe = undefined;
|
|
990
|
+
};
|
|
873
991
|
// Retained startup recovery makes the physical main identity ineligible. Routing
|
|
874
992
|
// can still preserve service by selecting a healthy configured pool account. A
|
|
875
993
|
// request-owned bearer likewise cannot inspect or reconcile file-main state.
|
|
@@ -900,6 +1018,23 @@ export async function resolveCodexAuthContext(
|
|
|
900
1018
|
const modelEligibleAccountIds = entitledAccountIds
|
|
901
1019
|
? new Set([...entitledAccountIds].filter(candidate => !excludeAccountIds?.has(candidate)))
|
|
902
1020
|
: undefined;
|
|
1021
|
+
// #4768: the flagships stay visible and never fail closed, so this is evidence routing may
|
|
1022
|
+
// ORDER by, not evidence it may refuse on. Read synchronously from rosters discovery has
|
|
1023
|
+
// already gathered -- no upstream fetch joins the request path for the most commonly
|
|
1024
|
+
// requested models in the product -- and passed to selection as a preference that is dropped
|
|
1025
|
+
// whenever honouring it would leave no candidate.
|
|
1026
|
+
//
|
|
1027
|
+
// Under the SAME exclusion the entitlement snapshot above uses. The reader validates each
|
|
1028
|
+
// cached roster against the account's current credential, and for native main that is a
|
|
1029
|
+
// synchronous read of the physical stored token -- exactly what this request is forbidden to
|
|
1030
|
+
// touch while a profile switch drains it or while it is served by a request-owned credential.
|
|
1031
|
+
// Excluding main here costs nothing: the preference is an ordering hint, so main becomes
|
|
1032
|
+
// unknown rather than denied, and unknown leaves selection exactly as it was.
|
|
1033
|
+
const deniedModelAccountIds = cachedDeniedCodexAccountIdsForModel(
|
|
1034
|
+
options.modelId,
|
|
1035
|
+
undefined,
|
|
1036
|
+
{ excludeAccountIds },
|
|
1037
|
+
);
|
|
903
1038
|
const selectionOptions = {
|
|
904
1039
|
// Temporary switch drain keeps the candidate until the atomic claim rejects
|
|
905
1040
|
// it. Retained recovery makes main wholly ineligible so pool routing continues.
|
|
@@ -911,13 +1046,21 @@ export async function resolveCodexAuthContext(
|
|
|
911
1046
|
? () => preserveRequestOwnedMainPin
|
|
912
1047
|
: options.isMainAccountTokenLive,
|
|
913
1048
|
modelEligibleAccountIds,
|
|
1049
|
+
deniedModelAccountIds,
|
|
1050
|
+
// Request-scoped and deliberately absent from `sharedStateSelectionOptions`: one
|
|
1051
|
+
// conversation's attachments say nothing about where unrelated threads should be served.
|
|
1052
|
+
retainAccountForUploadedFiles: options.retainAccountForUploadedFiles === true,
|
|
914
1053
|
};
|
|
915
1054
|
// A pre-drain selector reserves the native identity while reconciliation and
|
|
916
1055
|
// routing inspect it. Selectors arriving after the fence skip reconciliation
|
|
917
1056
|
// and may still route to non-main pool accounts without touching switch state.
|
|
918
1057
|
if (reserve && !nativeMainReadsForbidden && !selectionAdmission) throw new CodexMainProfileDrainingError();
|
|
919
1058
|
if (!nativeMainReadsForbidden) reconcileMainCodexAccountRuntimeState();
|
|
920
|
-
|
|
1059
|
+
// Annotated, not inferred. The two literals below carry neither `affinity` nor
|
|
1060
|
+
// `transientProbe`, so an inferred union makes `"k" in resolution` widen those reads to
|
|
1061
|
+
// `unknown` and a discriminant narrowing fail outright. Contextually typing every branch to
|
|
1062
|
+
// the resolver's own union is what lets the reads below stay total.
|
|
1063
|
+
const resolution: CodexThreadResolution = fixedAccountId !== undefined
|
|
921
1064
|
? { status: "selected" as const, accountId: fixedAccountId }
|
|
922
1065
|
: options.excludeAccountId
|
|
923
1066
|
? (() => {
|
|
@@ -942,8 +1085,17 @@ export async function resolveCodexAuthContext(
|
|
|
942
1085
|
lineage,
|
|
943
1086
|
);
|
|
944
1087
|
if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
|
|
1088
|
+
// THE REFUSAL. Every candidate is held, the recovery budget is spent, and no detour is
|
|
1089
|
+
// left -- so this request must not reach upstream at all. Returning the held account here
|
|
1090
|
+
// is what #4701 is about: under a provider-wide 503 that is every bound request piling
|
|
1091
|
+
// onto an account already known to be failing. Thrown before any credential is read, so
|
|
1092
|
+
// nothing is sent and nothing is spent.
|
|
1093
|
+
if (resolution.status === "withheld") {
|
|
1094
|
+
throw new CodexRecoveryWithheldError(resolution.accountId, resolution.retryAt, resolution.detourAccountId);
|
|
1095
|
+
}
|
|
945
1096
|
const selected = resolution.status === "selected" ? resolution.accountId : null;
|
|
946
|
-
affinityDecision =
|
|
1097
|
+
affinityDecision = resolution.affinity;
|
|
1098
|
+
transientProbe = resolution.status === "selected" ? resolution.transientProbe : undefined;
|
|
947
1099
|
if (!selected) {
|
|
948
1100
|
// A retry that excluded a failed Pool account may still use the validated caller-owned
|
|
949
1101
|
// main credential. Treating every exclusion as if main itself had failed strands a healthy
|
|
@@ -1025,12 +1177,25 @@ export async function resolveCodexAuthContext(
|
|
|
1025
1177
|
throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
|
|
1026
1178
|
}
|
|
1027
1179
|
}
|
|
1180
|
+
} catch (cause) {
|
|
1181
|
+
// Selection granted a trial and then a later policy check refused the account. The trial
|
|
1182
|
+
// never runs, so hand it back instead of leaving the held account unprobeable until its
|
|
1183
|
+
// deadline lapses (#4701).
|
|
1184
|
+
releaseTransientProbeGrant();
|
|
1185
|
+
throw cause;
|
|
1028
1186
|
} finally {
|
|
1029
1187
|
selectionAdmission?.release();
|
|
1030
1188
|
}
|
|
1031
1189
|
// Legacy selectors may retain an unusable account for actionable errors. A
|
|
1032
1190
|
// deferred credential must never become request auth through that fallback.
|
|
1033
|
-
|
|
1191
|
+
try {
|
|
1192
|
+
assertCodexAccountValidationReady(accountId);
|
|
1193
|
+
} catch (cause) {
|
|
1194
|
+
// Nothing will reach upstream, so give the trial back instead of leaving the held account
|
|
1195
|
+
// unprobeable until the lease deadline lapses (#4701).
|
|
1196
|
+
releaseTransientProbeGrant();
|
|
1197
|
+
throw cause;
|
|
1198
|
+
}
|
|
1034
1199
|
// Lazy prime: if the selected account has no quota yet, the pool is likely
|
|
1035
1200
|
// unprimed (dashboard never opened, or startup prime was blocked). Kick a
|
|
1036
1201
|
// best-effort prime so the NEXT routing decision has real scores. This never
|
|
@@ -1049,6 +1214,13 @@ export async function resolveCodexAuthContext(
|
|
|
1049
1214
|
// a literal Retry-After reads very differently to a user than a reset-derived guess.
|
|
1050
1215
|
const cooldown = getCodexQuotaHealthSnapshot(accountId, quotaScope);
|
|
1051
1216
|
const cooldownUntil = cooldown?.cooldownUntil;
|
|
1217
|
+
// A transient-hold trial and a quota cooldown cannot both describe this account:
|
|
1218
|
+
// `isTransientOnlyAffinityBlock` refuses to recognise a transient hold on an account carrying
|
|
1219
|
+
// quota health, so the cooldown branch below is unreachable while a trial is held. That is
|
|
1220
|
+
// also why no request pays two recovery permits for one send. The release is defensive --
|
|
1221
|
+
// should that invariant ever move, the trial is handed back rather than stranded behind a
|
|
1222
|
+
// refusal that belongs to the other domain.
|
|
1223
|
+
if (cooldownUntil && transientProbe) releaseTransientProbeGrant();
|
|
1052
1224
|
// A cooled-down account never sends traffic, so upstream recovery can never be
|
|
1053
1225
|
// observed and the cooldown outlives the real limit. Admit one probe per
|
|
1054
1226
|
// interval; its outcome decides whether the cooldown ends (#433).
|
|
@@ -1089,6 +1261,7 @@ export async function resolveCodexAuthContext(
|
|
|
1089
1261
|
if (token) mainQuotaWriter = observeSelectedMainCredential(token, mainQuotaWriter);
|
|
1090
1262
|
assertMainAccountPolicy(policy);
|
|
1091
1263
|
} catch (cause) {
|
|
1264
|
+
releaseTransientProbeGrant();
|
|
1092
1265
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1093
1266
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1094
1267
|
if (cause instanceof CodexMainAccountHardLockError) throw cause;
|
|
@@ -1099,15 +1272,23 @@ export async function resolveCodexAuthContext(
|
|
|
1099
1272
|
}
|
|
1100
1273
|
if (!token) {
|
|
1101
1274
|
// Nothing will reach upstream, so give the probe back instead of burning it.
|
|
1275
|
+
releaseTransientProbeGrant();
|
|
1102
1276
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1103
1277
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1104
1278
|
throw new CodexPoolAuthenticationError(
|
|
1105
1279
|
fixedAccountId !== undefined ? "Selected Codex account is unavailable" : undefined,
|
|
1106
1280
|
);
|
|
1107
1281
|
}
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1282
|
+
let reserveAuthorization: MainReserveAuthorization | undefined;
|
|
1283
|
+
try {
|
|
1284
|
+
reserveAuthorization = reserve
|
|
1285
|
+
? await authorizeReserveCredential(token, mainQuotaWriter, policy, options.signal, undefined, writerGeneration)
|
|
1286
|
+
: undefined;
|
|
1287
|
+
} catch (cause) {
|
|
1288
|
+
// A Reserve refusal ends the request here, so the trial it was holding never runs.
|
|
1289
|
+
releaseTransientProbeGrant();
|
|
1290
|
+
throw cause;
|
|
1291
|
+
}
|
|
1111
1292
|
return {
|
|
1112
1293
|
kind: "main-pool",
|
|
1113
1294
|
accountId,
|
|
@@ -1121,6 +1302,7 @@ export async function resolveCodexAuthContext(
|
|
|
1121
1302
|
...(quotaScope ? { quotaScope } : {}),
|
|
1122
1303
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1123
1304
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1305
|
+
...(transientProbe ? { transientProbe } : {}),
|
|
1124
1306
|
};
|
|
1125
1307
|
}
|
|
1126
1308
|
|
|
@@ -1141,8 +1323,10 @@ export async function resolveCodexAuthContext(
|
|
|
1141
1323
|
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
1142
1324
|
...(probeQuotaScope ? { probeQuotaScope } : {}),
|
|
1143
1325
|
...(affinityDecision ? { affinityDecision } : {}),
|
|
1326
|
+
...(transientProbe ? { transientProbe } : {}),
|
|
1144
1327
|
};
|
|
1145
1328
|
} catch (cause) {
|
|
1329
|
+
releaseTransientProbeGrant();
|
|
1146
1330
|
if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
|
|
1147
1331
|
else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
1148
1332
|
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[],
|
|
@@ -290,6 +290,7 @@ export function filterCatalogVisibleModels(
|
|
|
290
290
|
}
|
|
291
291
|
return models.filter(m => {
|
|
292
292
|
if (initialModelSelectionPending(config.providers[m.provider])) return false;
|
|
293
|
+
if (config.providers[m.provider]?.disabled === true) return false;
|
|
293
294
|
const nativeAlias = m.provider === COMBO_NAMESPACE && m.nativeAlias === true;
|
|
294
295
|
// disabledModels may be stored raw (canonical) or encoded (legacy UI writes).
|
|
295
296
|
for (const stored of disabled) {
|
|
@@ -113,12 +113,42 @@ function safeTimeout(value: number | undefined): number {
|
|
|
113
113
|
? Math.min(Math.floor(value), 120_000) : DEFAULT_TIMEOUT_MS;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** Match Bun fetch's environment routing, not the broader WebSocket NO_PROXY grammar. */
|
|
117
|
+
function catalogRequestUsesBunHttpProxy(url: URL): boolean {
|
|
118
|
+
if (url.protocol !== "http:") return false;
|
|
119
|
+
const proxy = process.env.http_proxy || process.env.HTTP_PROXY;
|
|
120
|
+
if (!proxy || proxy === '""' || proxy === "''") return false;
|
|
121
|
+
const hostname = url.hostname.toLowerCase();
|
|
122
|
+
const host = url.host.toLowerCase();
|
|
123
|
+
// Bun env_loader::is_no_proxy (1.4.2): lowercase wins unless empty, ASCII
|
|
124
|
+
// whitespace only, no scheme/path/wildcard/bracket/trailing-dot normalization.
|
|
125
|
+
const bypasses = process.env.no_proxy || process.env.NO_PROXY || "";
|
|
126
|
+
for (let entry of bypasses.split(",")) {
|
|
127
|
+
entry = entry.replace(/^[ \t\n\r\v\f]+|[ \t\n\r\v\f]+$/g, "")
|
|
128
|
+
.replace(/[A-Z]/g, letter => letter.toLowerCase());
|
|
129
|
+
if (entry === "*") return false;
|
|
130
|
+
if (entry.startsWith(".")) entry = entry.slice(1);
|
|
131
|
+
if (!entry) continue;
|
|
132
|
+
const hasPort = entry.startsWith("[")
|
|
133
|
+
? entry.includes("]:")
|
|
134
|
+
: (entry.match(/:/g)?.length ?? 0) === 1;
|
|
135
|
+
if (hasPort ? host === entry : hostname === entry || hostname.endsWith(`.${entry}`)) return false;
|
|
136
|
+
}
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
|
|
116
140
|
export async function fetchRemoteCatalog(
|
|
117
141
|
input: string,
|
|
118
142
|
options: Pick<PullRemoteCatalogOptions, "token" | "timeoutMs" | "maxBytes" | "fetchImpl"> = {},
|
|
119
143
|
): Promise<{ document: RemoteCatalogDocument; content: string }> {
|
|
120
144
|
const url = validateRemoteCatalogUrl(input);
|
|
121
145
|
const token = validateToken(options.token);
|
|
146
|
+
if (catalogRequestUsesBunHttpProxy(url)) {
|
|
147
|
+
throw new RemoteCatalogError(
|
|
148
|
+
"insecure_http_refused",
|
|
149
|
+
"Loopback HTTP catalog requests must bypass outbound HTTP proxy routing",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
122
152
|
const headers = new Headers({ Accept: "application/json" });
|
|
123
153
|
if (token !== undefined) headers.set("Authorization", `Bearer ${token}`);
|
|
124
154
|
let response: Response;
|
|
@@ -51,7 +51,7 @@ import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
|
|
|
51
51
|
import { isMultiAgentV2Enabled } from "../features";
|
|
52
52
|
import { clampCatalogModelsToCodexSupport } from "./effort";
|
|
53
53
|
import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
|
|
54
|
-
import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
|
|
54
|
+
import { dedupeCatalogEntriesBySlug, enforceCatalogSlugUniqueness, exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
|
|
55
55
|
import {
|
|
56
56
|
withCatalogWriteSerialization,
|
|
57
57
|
type CatalogWritePermit,
|
|
@@ -522,6 +522,9 @@ function writeRetainedCatalogSync({
|
|
|
522
522
|
});
|
|
523
523
|
clampCatalogModelsToCodexSupport(catalog.models);
|
|
524
524
|
finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
|
|
525
|
+
// Last mutation before serialization; see `enforceCatalogSlugUniqueness` for why the ordering
|
|
526
|
+
// against the effort clamp is load-bearing rather than cosmetic.
|
|
527
|
+
catalog.models = enforceCatalogSlugUniqueness(catalog.models, true);
|
|
525
528
|
|
|
526
529
|
const added = goEntries.length + accountBoundEntries.length;
|
|
527
530
|
const content = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
@@ -553,6 +556,11 @@ function writeRetainedCatalogSync({
|
|
|
553
556
|
};
|
|
554
557
|
}
|
|
555
558
|
|
|
559
|
+
// Re-exported so the #4730 unit regression keeps importing the guard from the sync module it
|
|
560
|
+
// guards; the implementation lives in ./aggregation because the management convergence commit
|
|
561
|
+
// is the second writer that has to apply the identical rule.
|
|
562
|
+
export { dedupeCatalogEntriesBySlug };
|
|
563
|
+
|
|
556
564
|
export async function syncCatalogModels(
|
|
557
565
|
config: OcxConfig,
|
|
558
566
|
options?: CodexCatalogSyncOptions,
|
|
@@ -420,6 +420,42 @@ async function gatherRoutedModelsUncached(
|
|
|
420
420
|
if (!memberByKey.has(key)) memberByKey.set(key, synthetic);
|
|
421
421
|
}
|
|
422
422
|
}
|
|
423
|
+
// [Decision Log]
|
|
424
|
+
// - 목적과 의도: combo derivation must see the same explicit custom-model capabilities that the
|
|
425
|
+
// final Models inventory publishes. Previously customModels were materialized only after this
|
|
426
|
+
// map had already derived every combo, so one row could say image while its combo said text.
|
|
427
|
+
// - 기존 구현 및 제약 조건: provider/discovery rows remain the inheritance source, and native
|
|
428
|
+
// OpenAI synthesis must run first so a sparse custom row cannot hide native hard limits.
|
|
429
|
+
// - 검토한 주요 대안: move the full custom-row materializer ahead of combos, or overlay only the
|
|
430
|
+
// explicit custom fields onto this private derivation map after provider/native inheritance.
|
|
431
|
+
// - 선택한 방식: use the scoped post-inheritance overlay; the existing final materializer stays
|
|
432
|
+
// the single owner of public custom-row construction and deduplication.
|
|
433
|
+
// - 다른 대안 대신 이 방식을 선택한 이유: moving the large materializer would reorder public
|
|
434
|
+
// catalog production and warning behavior, while this map is already private to combo input.
|
|
435
|
+
// - 장점, 단점 및 영향: custom context/modality/reasoning/tool-mode declarations now constrain
|
|
436
|
+
// their combos without widening unrelated rows; omitted fields retain provider/native limits.
|
|
437
|
+
for (const custom of config.customModels ?? []) {
|
|
438
|
+
const key = `${custom.provider}/${custom.modelId}`;
|
|
439
|
+
const inherited = memberByKey.get(key) ?? {
|
|
440
|
+
provider: custom.provider,
|
|
441
|
+
id: custom.modelId,
|
|
442
|
+
owned_by: custom.provider,
|
|
443
|
+
};
|
|
444
|
+
memberByKey.set(key, {
|
|
445
|
+
...inherited,
|
|
446
|
+
catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND,
|
|
447
|
+
...(typeof custom.contextWindow === "number" && custom.contextWindow > 0
|
|
448
|
+
? { contextWindow: custom.contextWindow }
|
|
449
|
+
: {}),
|
|
450
|
+
...(Array.isArray(custom.inputModalities)
|
|
451
|
+
? { inputModalities: [...custom.inputModalities] }
|
|
452
|
+
: {}),
|
|
453
|
+
...(Array.isArray(custom.reasoningEfforts)
|
|
454
|
+
? { reasoningEfforts: [...custom.reasoningEfforts] }
|
|
455
|
+
: {}),
|
|
456
|
+
...(custom.codexToolMode !== undefined ? { codexToolMode: custom.codexToolMode } : {}),
|
|
457
|
+
});
|
|
458
|
+
}
|
|
423
459
|
// Enriched (registry-hydrated) provider clones — shared by combo member synthesis and
|
|
424
460
|
// custom-model vision-sidecar inheritance so both see the same merged registry view.
|
|
425
461
|
const enrichedByName = new Map(activeProviders.map(provider => [provider.name, provider.provider]));
|
|
@@ -480,7 +516,8 @@ async function gatherRoutedModelsUncached(
|
|
|
480
516
|
// with the same slug below, so that row's provider capability metadata is the inheritance source.
|
|
481
517
|
const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
|
|
482
518
|
const customModels = (config.customModels ?? []).map(cm => {
|
|
483
|
-
const rawProvider = config.providers[cm.provider]
|
|
519
|
+
const rawProvider = config.providers[cm.provider]?.disabled !== true
|
|
520
|
+
? config.providers[cm.provider] : undefined;
|
|
484
521
|
const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider;
|
|
485
522
|
// Registry routing backfills an omitted authMode on the built-in OpenAI provider to
|
|
486
523
|
// forward. Keep the catalog projection on the same contract while still failing closed
|
|
@@ -587,8 +587,14 @@ export async function inspectCodexCliInstall(
|
|
|
587
587
|
const platform = deps.platform ?? process.platform;
|
|
588
588
|
const candidate = observeCodexRuntimeCandidateReadOnly(deps);
|
|
589
589
|
if (!candidate) {
|
|
590
|
+
// This slice reads no candidate or configuration file on Windows, so an
|
|
591
|
+
// absent proof-captured environment candidate does not establish that no
|
|
592
|
+
// Codex CLI exists: a persisted selection is simply never consulted there.
|
|
593
|
+
// Report the deferral that actually happened instead of the stronger claim
|
|
594
|
+
// that the candidate is unavailable. POSIX retains its existing result
|
|
595
|
+
// when no candidate is observed.
|
|
590
596
|
return isWindowsPlatform(platform)
|
|
591
|
-
? unknownWindowsReport("
|
|
597
|
+
? unknownWindowsReport("windows_inspection_deferred")
|
|
592
598
|
: unknownReport("candidate_unavailable");
|
|
593
599
|
}
|
|
594
600
|
|
package/src/codex/convergence.ts
CHANGED
|
@@ -49,7 +49,7 @@ import {
|
|
|
49
49
|
orderForSubagents,
|
|
50
50
|
} from "./catalog/sync";
|
|
51
51
|
import { multiAgentV2EnabledFromConfigText } from "./features";
|
|
52
|
-
import { exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
52
|
+
import { enforceCatalogSlugUniqueness, exactComboCatalogSlugs } from "./catalog/aggregation";
|
|
53
53
|
import {
|
|
54
54
|
isNativeAliasCatalogEntry,
|
|
55
55
|
accountBoundNativeOpenAiSlugs,
|
|
@@ -386,7 +386,12 @@ function prepareCatalog(
|
|
|
386
386
|
: null,
|
|
387
387
|
);
|
|
388
388
|
finalizeAutoReviewModelOverride(mergedModels, catalogModels, config);
|
|
389
|
-
|
|
389
|
+
// The second writer of this file. A dashboard model toggle, a combo edit, or a Codex account
|
|
390
|
+
// login reaches `convergeCodexCatalog` and commits through `fixedCommit`, never through
|
|
391
|
+
// `writeRetainedCatalogSync`, so the #4730 uniqueness guard has to stand here too or the same
|
|
392
|
+
// `source-invalid` rejection returns by a different route. Silent because this merge runs under
|
|
393
|
+
// `warningPolicy: "suppress"`.
|
|
394
|
+
catalog.models = enforceCatalogSlugUniqueness(mergedModels, false);
|
|
390
395
|
return catalog;
|
|
391
396
|
}
|
|
392
397
|
|