@bitkyc08/opencodex 2.55.0 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- 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-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- 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 +611 -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 +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- 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 +554 -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 +422 -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 +95 -28
- 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 +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -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 +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- 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/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/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 +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- 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 +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -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/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 +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -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 +335 -0
- package/src/server/index.ts +40 -2547
- 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/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -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 +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { loadConfig, websocketsEnabled } from "../../config";
|
|
4
|
+
import { shouldSyncCodexOnStart } from "../desired-state";
|
|
5
|
+
import { legacyCustomModelCatalogSlugs } from "../custom-model-catalog-migration";
|
|
6
|
+
import { getCodexHome } from "../paths";
|
|
7
|
+
import type { OcxConfig } from "../../types";
|
|
8
|
+
import { pendingModelSelectionProviders } from "../../providers/initial-model-selection";
|
|
9
|
+
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
10
|
+
import { providerCodexAccountMode } from "../../providers/registry";
|
|
11
|
+
import { COMBO_NAMESPACE } from "../../combos";
|
|
12
|
+
import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "../account-namespaces";
|
|
13
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
14
|
+
import {
|
|
15
|
+
availableAccountGatedNativeModels,
|
|
16
|
+
codexModelEntitlementStateForAccount,
|
|
17
|
+
isCodexModelEntitlementSnapshotCurrent,
|
|
18
|
+
resolveCodexModelEntitlements,
|
|
19
|
+
type CodexModelEntitlementSnapshot,
|
|
20
|
+
} from "../model-entitlements";
|
|
21
|
+
import { isAccountNeedsReauth } from "../account-runtime-state";
|
|
22
|
+
import { codexRuntimeStatePath } from "../runtime";
|
|
23
|
+
import {
|
|
24
|
+
activeCodexModelsCachePath,
|
|
25
|
+
catalogBackupPathFor,
|
|
26
|
+
catalogHasRoutedEntries,
|
|
27
|
+
findNativeTemplate,
|
|
28
|
+
findSupportedNativeTemplate,
|
|
29
|
+
isDefaultCatalogPath,
|
|
30
|
+
legacyCatalogBackupPath,
|
|
31
|
+
readCatalog,
|
|
32
|
+
readCatalogBackup,
|
|
33
|
+
readCodexCatalogPath,
|
|
34
|
+
readCodexCatalogPathForHome,
|
|
35
|
+
readNativeBaseline,
|
|
36
|
+
} from "./parsing";
|
|
37
|
+
import type { CatalogModel, MultiAgentMode, RawCatalog, RawEntry } from "./parsing";
|
|
38
|
+
import {
|
|
39
|
+
accountBoundNativeOpenAiSlugsBySelector,
|
|
40
|
+
desktopAllowlistSuppressedNativeSlugs,
|
|
41
|
+
disabledNativeSlugs,
|
|
42
|
+
nativeContextLimits,
|
|
43
|
+
observedAccountBoundNativeEntries,
|
|
44
|
+
observedReserveCatalogSource,
|
|
45
|
+
shouldIncludeAccountBoundNativeOpenAi,
|
|
46
|
+
shouldIncludeNativeOpenAi,
|
|
47
|
+
upstreamNativeEntry,
|
|
48
|
+
} from "./metadata";
|
|
49
|
+
import { trustedAccountBoundNativeCatalogSlug } from "./account-models";
|
|
50
|
+
import { bundledCatalogCacheState, loadBundledCodexCatalog } from "./bundled";
|
|
51
|
+
import { isMultiAgentV2Enabled } from "../features";
|
|
52
|
+
import { clampCatalogModelsToCodexSupport } from "./effort";
|
|
53
|
+
import { filterCatalogVisibleModels, gatherRoutedModels, type CatalogGatherProviderModelOutcome } from "./provider-fetch";
|
|
54
|
+
import { exactComboCatalogSlugs, type ComboCatalogOmission } from "./aggregation";
|
|
55
|
+
import {
|
|
56
|
+
withCatalogWriteSerialization,
|
|
57
|
+
type CatalogWritePermit,
|
|
58
|
+
} from "../catalog-write-serialization";
|
|
59
|
+
import {
|
|
60
|
+
publishHashedCodexCatalogBackup,
|
|
61
|
+
publishLegacyCodexCatalogBackup,
|
|
62
|
+
replaceActiveCodexCatalog,
|
|
63
|
+
replaceCodexModelsCache,
|
|
64
|
+
} from "../internal/catalog-writer";
|
|
65
|
+
import { visibleCodexAccountSelectors } from "./account-models";
|
|
66
|
+
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./native-models";
|
|
67
|
+
import { createReserveCatalogProjection, RESERVE_LUNA_METADATA_SOURCE, RESERVE_SOURCE_CATALOG_FIELD } from "./reserve";
|
|
68
|
+
import {
|
|
69
|
+
CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
|
|
70
|
+
buildCatalogEntriesFromObservedState,
|
|
71
|
+
mergeCatalogEntriesFromObservedState,
|
|
72
|
+
mergeCatalogModelsWithNativeRecovery,
|
|
73
|
+
orderForSubagents,
|
|
74
|
+
} from "./build-entries";
|
|
75
|
+
import { finishUpstreamNativeEntry } from "./derive-entry";
|
|
76
|
+
import { finalizeAutoReviewModelOverride } from "./auto-review";
|
|
77
|
+
import { gatedNativeAccountLabel, gatedNativeReauthSuppressionReason, warnGatedNativeSuppressedOnce } from "./gated-native-warn";
|
|
78
|
+
|
|
79
|
+
interface RetainedCatalogSyncRead {
|
|
80
|
+
readonly catalogPath: string;
|
|
81
|
+
readonly catalog: RawCatalog;
|
|
82
|
+
readonly onDiskCatalog: RawCatalog | null;
|
|
83
|
+
readonly modelsCache: RawCatalog | null;
|
|
84
|
+
readonly evidence: string;
|
|
85
|
+
/**
|
|
86
|
+
* Process-local epochs, baselined AFTER our own gather rather than with the
|
|
87
|
+
* filesystem bytes above. See `retainedCatalogProcessEvidence`.
|
|
88
|
+
*/
|
|
89
|
+
readonly processEvidence: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
interface RetainedCatalogSyncResult {
|
|
93
|
+
added: number;
|
|
94
|
+
path: string;
|
|
95
|
+
catalogWritten: boolean;
|
|
96
|
+
comboOmissions: ComboCatalogOmission[];
|
|
97
|
+
/** Validated catalog commit (including identical bytes), or a refused refresh. */
|
|
98
|
+
refreshOutcome?: "committed" | "refused";
|
|
99
|
+
/** `desired_disabled` observed under K after the provider await; nothing was written. */
|
|
100
|
+
skippedReason?: "desired_disabled";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Catalog/cache commit overrides.
|
|
105
|
+
*
|
|
106
|
+
* An explicit `ocx sync` is also the refresh path for side profiles that consume
|
|
107
|
+
* the OpenCodex catalog without injection (for example a custom `model_provider`
|
|
108
|
+
* that routes to the proxy). In that mode the Codex integration toggle only
|
|
109
|
+
* governs config/history injection; the catalog and models cache may still be
|
|
110
|
+
* refreshed, so `allowWhenDesiredDisabled` lets the commit path ignore the OFF
|
|
111
|
+
* gate that otherwise protects a fully native home.
|
|
112
|
+
*/
|
|
113
|
+
export interface CodexCatalogSyncOptions {
|
|
114
|
+
allowWhenDesiredDisabled?: boolean;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
interface RetainedCatalogSyncWrite {
|
|
118
|
+
readonly config: OcxConfig;
|
|
119
|
+
readonly goModels: CatalogModel[];
|
|
120
|
+
readonly providerModelOutcomes: readonly CatalogGatherProviderModelOutcome[];
|
|
121
|
+
readonly comboOmissions: ComboCatalogOmission[];
|
|
122
|
+
readonly read: RetainedCatalogSyncRead;
|
|
123
|
+
readonly permit: CatalogWritePermit;
|
|
124
|
+
readonly owningCodexHome: string;
|
|
125
|
+
readonly modelEntitlements: CodexModelEntitlementSnapshot;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function optionalFileBytes(path: string): string | null {
|
|
129
|
+
try {
|
|
130
|
+
return readFileSync(path).toString("base64");
|
|
131
|
+
} catch (error) {
|
|
132
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return null;
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function loadCatalogForRetainedSync(path: string): RawCatalog | null {
|
|
138
|
+
const bundled = isDefaultCatalogPath(path) ? loadBundledCodexCatalog() : null;
|
|
139
|
+
if (bundled) return JSON.parse(JSON.stringify(bundled)) as RawCatalog;
|
|
140
|
+
const active = readCatalog(path);
|
|
141
|
+
// A valid configured custom file remains the content authority even when it has no bare native
|
|
142
|
+
// template. The null-template builder is deliberate; a stale backup must not replace active
|
|
143
|
+
// custom root metadata merely because the current file contains only routed rows.
|
|
144
|
+
if (active && (!isDefaultCatalogPath(path) || findNativeTemplate(active))) return active;
|
|
145
|
+
return readCatalog(catalogBackupPathFor(path))
|
|
146
|
+
?? (isDefaultCatalogPath(path) ? readCatalog(legacyCatalogBackupPath()) : null)
|
|
147
|
+
?? readCatalog(activeCodexModelsCachePath())
|
|
148
|
+
?? active;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function retainedCatalogSyncEvidence(
|
|
152
|
+
config: OcxConfig,
|
|
153
|
+
catalogPath: string,
|
|
154
|
+
catalog: RawCatalog,
|
|
155
|
+
): string {
|
|
156
|
+
return JSON.stringify({
|
|
157
|
+
config,
|
|
158
|
+
catalogPath,
|
|
159
|
+
catalog,
|
|
160
|
+
catalogBytes: optionalFileBytes(catalogPath),
|
|
161
|
+
hashedBackupBytes: optionalFileBytes(catalogBackupPathFor(catalogPath)),
|
|
162
|
+
legacyBackupBytes: isDefaultCatalogPath(catalogPath)
|
|
163
|
+
? optionalFileBytes(legacyCatalogBackupPath()) : null,
|
|
164
|
+
modelsCacheBytes: optionalFileBytes(activeCodexModelsCachePath()),
|
|
165
|
+
// The persisted runtime selection is a pre-await filesystem input, not a
|
|
166
|
+
// process epoch: another PROCESS can move runtime authority by rewriting this
|
|
167
|
+
// file, and that move is invisible to our in-process memo. Recorded PRESENT or
|
|
168
|
+
// ABSENT, because its absence is what makes the resolver fall back.
|
|
169
|
+
runtimeStateBytes: optionalFileBytes(codexRuntimeStatePath()),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The bundled-template half of the same evidence, observed separately.
|
|
175
|
+
*
|
|
176
|
+
* The runtime process memo is deliberately NOT here, and that exclusion took three
|
|
177
|
+
* attempts to get honest. Gathering resolves the Codex runtime lazily and under its
|
|
178
|
+
* own cache key, so this path cannot pre-settle that memo: baselining it before the
|
|
179
|
+
* await always detected our own side effect and refused every write, and baselining
|
|
180
|
+
* it after the await captured a runtime that ANOTHER process had moved as though it
|
|
181
|
+
* were ours — a catalog prepared from R1 committing after authority reached R2.
|
|
182
|
+
*
|
|
183
|
+
* Runtime authority is covered where it is actually durable instead: the persisted
|
|
184
|
+
* `codex-runtime.json` bytes sit in the pre-await filesystem evidence, PRESENT or
|
|
185
|
+
* ABSENT, so a cross-process runtime move is caught. What is left uncovered, and is
|
|
186
|
+
* written down rather than papered over, is a same-process in-memory runtime swap
|
|
187
|
+
* that never touches that file — WP11 owns the lock that makes that case decidable.
|
|
188
|
+
*/
|
|
189
|
+
function retainedCatalogProcessEvidence(): string {
|
|
190
|
+
return JSON.stringify({
|
|
191
|
+
bundledCatalogCache: bundledCatalogCacheState(),
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Capture every local catalog input the retained sync path consults before its
|
|
197
|
+
* provider await. The exact evidence is compared after K acquisition; a newer
|
|
198
|
+
* catalog/backup/cache or target selection makes this attempt a no-write.
|
|
199
|
+
*/
|
|
200
|
+
function readRetainedCatalogSync(config: OcxConfig): RetainedCatalogSyncRead | null {
|
|
201
|
+
const catalogPath = readCodexCatalogPath();
|
|
202
|
+
const catalog = loadCatalogForRetainedSync(catalogPath);
|
|
203
|
+
if (!catalog) return null;
|
|
204
|
+
|
|
205
|
+
// The bundled catalog is a reliable native template on the default path, but it is not the
|
|
206
|
+
// merge source. Preservation must inspect the file that this sync is about to overwrite;
|
|
207
|
+
// otherwise an empty/partial provider gather cannot see routed or user-native rows on disk.
|
|
208
|
+
const onDiskCatalog = readCatalog(catalogPath);
|
|
209
|
+
const modelsCache = readCatalog(activeCodexModelsCachePath());
|
|
210
|
+
const evidence = retainedCatalogSyncEvidence(config, catalogPath, catalog);
|
|
211
|
+
// `processEvidence` is filled in after the provider await, not here.
|
|
212
|
+
return { catalogPath, catalog, onDiskCatalog, modelsCache, evidence, processEvidence: "" };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function revalidateRetainedCatalogSync(
|
|
216
|
+
config: OcxConfig,
|
|
217
|
+
prepared: RetainedCatalogSyncRead,
|
|
218
|
+
): RetainedCatalogSyncRead | null {
|
|
219
|
+
const catalogPath = readCodexCatalogPath();
|
|
220
|
+
if (catalogPath !== prepared.catalogPath) return null;
|
|
221
|
+
const evidence = retainedCatalogSyncEvidence(config, catalogPath, prepared.catalog);
|
|
222
|
+
if (evidence !== prepared.evidence) return null;
|
|
223
|
+
if (retainedCatalogProcessEvidence() !== prepared.processEvidence) return null;
|
|
224
|
+
return {
|
|
225
|
+
catalogPath,
|
|
226
|
+
catalog: JSON.parse(JSON.stringify(prepared.catalog)) as RawCatalog,
|
|
227
|
+
onDiskCatalog: readCatalog(catalogPath),
|
|
228
|
+
modelsCache: readCatalog(activeCodexModelsCachePath()),
|
|
229
|
+
evidence,
|
|
230
|
+
processEvidence: prepared.processEvidence,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Exact bytes currently on disk at `path`, or null when unreadable/absent.
|
|
236
|
+
*
|
|
237
|
+
* Deliberately a Buffer rather than a decoded string: `readFileSync(path, "utf8")`
|
|
238
|
+
* substitutes U+FFFD for every invalid byte, so a file holding a raw 0x80 decodes
|
|
239
|
+
* equal to prepared content holding a legitimately encoded U+FFFD. Comparing the
|
|
240
|
+
* decoded strings would then classify a malformed catalog as identical, skip the
|
|
241
|
+
* atomic repair write, and leave the corruption on disk while reporting
|
|
242
|
+
* `catalogWritten: false`.
|
|
243
|
+
*/
|
|
244
|
+
function currentCatalogFileContent(path: string): Buffer | null {
|
|
245
|
+
try {
|
|
246
|
+
return readFileSync(path);
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function pristineCatalogBytes(read: RetainedCatalogSyncRead): string | null {
|
|
253
|
+
if (read.onDiskCatalog && !catalogHasRoutedEntries(read.onDiskCatalog)) {
|
|
254
|
+
try {
|
|
255
|
+
return readFileSync(read.catalogPath, "utf8");
|
|
256
|
+
} catch {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return catalogHasRoutedEntries(read.catalog)
|
|
261
|
+
? null
|
|
262
|
+
: `${JSON.stringify(read.catalog, null, 2)}\n`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function catalogModelsForMergeWithNativeRecovery(
|
|
266
|
+
catalogPath: string,
|
|
267
|
+
catalog: RawCatalog,
|
|
268
|
+
onDiskCatalog: RawCatalog | null,
|
|
269
|
+
): RawEntry[] {
|
|
270
|
+
const primaryCatalogModels = onDiskCatalog?.models ?? catalog.models ?? [];
|
|
271
|
+
// Native-alias compatibility can omit disabled native rows from the effective catalog because
|
|
272
|
+
// Desktop's remote allowlist ignores `visibility: "hide"`. Keep current/pristine native recovery
|
|
273
|
+
// sources beside the on-disk rows so re-enabling a model restores its real metadata. Routed and
|
|
274
|
+
// user-authored rows still come only from the on-disk catalog.
|
|
275
|
+
return mergeCatalogModelsWithNativeRecovery(primaryCatalogModels, [
|
|
276
|
+
catalog.models ?? [],
|
|
277
|
+
readCatalogBackup(catalogPath)?.models ?? [],
|
|
278
|
+
]);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function writeRetainedCatalogSync({
|
|
282
|
+
config,
|
|
283
|
+
goModels,
|
|
284
|
+
providerModelOutcomes,
|
|
285
|
+
comboOmissions,
|
|
286
|
+
read,
|
|
287
|
+
permit,
|
|
288
|
+
owningCodexHome,
|
|
289
|
+
modelEntitlements,
|
|
290
|
+
}: RetainedCatalogSyncWrite): RetainedCatalogSyncResult {
|
|
291
|
+
const { catalogPath, catalog, onDiskCatalog } = read;
|
|
292
|
+
const catalogModelsForMerge = catalogModelsForMergeWithNativeRecovery(
|
|
293
|
+
catalogPath,
|
|
294
|
+
catalog,
|
|
295
|
+
onDiskCatalog,
|
|
296
|
+
);
|
|
297
|
+
// Strict selector for template inheritance; the validity gate above keeps the broad one.
|
|
298
|
+
const template = findSupportedNativeTemplate(catalog);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
// Once-only: preserve the PRISTINE pre-opencodex catalog as the native-priority baseline
|
|
302
|
+
// (later syncs would otherwise overwrite it with featured-modified priorities).
|
|
303
|
+
const pristine = pristineCatalogBytes(read);
|
|
304
|
+
if (pristine !== null) {
|
|
305
|
+
publishHashedCodexCatalogBackup(permit, owningCodexHome, {
|
|
306
|
+
path: catalogBackupPathFor(catalogPath),
|
|
307
|
+
content: pristine,
|
|
308
|
+
});
|
|
309
|
+
if (isDefaultCatalogPath(catalogPath)) {
|
|
310
|
+
publishLegacyCodexCatalogBackup(permit, owningCodexHome, {
|
|
311
|
+
path: legacyCatalogBackupPath(),
|
|
312
|
+
content: pristine,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch { /* backup best-effort */ }
|
|
317
|
+
|
|
318
|
+
// Hide disabled models from Codex, then feature the chosen subagent models (native OR routed)
|
|
319
|
+
// by giving them the lowest priority — see buildCatalogEntries for why priority, not array order.
|
|
320
|
+
const enabledGo = filterCatalogVisibleModels(goModels, config);
|
|
321
|
+
const featured = config.subagentModels ?? [];
|
|
322
|
+
const orderedGoModels = orderForSubagents(enabledGo, featured); // stable tie-break among equal priorities
|
|
323
|
+
const modelPickerOrder = config.modelPickerOrder ?? [];
|
|
324
|
+
const multiAgentMode: MultiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
|
|
325
|
+
const exactComboSlugs = exactComboCatalogSlugs(config);
|
|
326
|
+
const bareEligibleAccountIds = providerCodexAccountMode(
|
|
327
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
328
|
+
config.providers[OPENAI_CODEX_PROVIDER_ID],
|
|
329
|
+
) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
|
|
330
|
+
const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
|
|
331
|
+
modelEntitlements,
|
|
332
|
+
bareEligibleAccountIds,
|
|
333
|
+
);
|
|
334
|
+
const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
|
|
335
|
+
const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
|
|
336
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
|
|
337
|
+
));
|
|
338
|
+
const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
|
|
339
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
|
|
340
|
+
));
|
|
341
|
+
const unavailableGatedNativeSlugs = new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => (
|
|
342
|
+
!availableBareGatedNativeSlugs.has(slug)
|
|
343
|
+
)));
|
|
344
|
+
// #4212: this set is the whole record of a model vanishing, and it is a set of strings that
|
|
345
|
+
// nothing downstream ever asks a question of. Explain it here, while the entitlement snapshot
|
|
346
|
+
// that produced it is still in scope, because after this point the model is simply absent and
|
|
347
|
+
// no later surface can tell "never entitled" apart from "the account broke this morning".
|
|
348
|
+
for (const slug of unavailableGatedNativeSlugs) {
|
|
349
|
+
const reason = gatedNativeReauthSuppressionReason({
|
|
350
|
+
snapshot: modelEntitlements,
|
|
351
|
+
slug,
|
|
352
|
+
eligibleAccountIds: bareEligibleAccountIds,
|
|
353
|
+
needsReauth: isAccountNeedsReauth,
|
|
354
|
+
label: accountId => gatedNativeAccountLabel(config, accountId),
|
|
355
|
+
});
|
|
356
|
+
if (reason) warnGatedNativeSuppressedOnce(slug, reason);
|
|
357
|
+
}
|
|
358
|
+
const suppressedBareNativeSlugs = new Set([
|
|
359
|
+
...desktopAllowlistSuppressedNativeSlugs(config),
|
|
360
|
+
...unavailableGatedNativeSlugs,
|
|
361
|
+
]);
|
|
362
|
+
const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
|
|
363
|
+
const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
|
|
364
|
+
const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
|
|
365
|
+
// Both user levers. Passing only the cap here is what let a per-model window the dashboard
|
|
366
|
+
// had accepted get written back at full width in the on-disk catalog.
|
|
367
|
+
const openaiContextCap = nativeContextLimits(config);
|
|
368
|
+
const accountSelectors = includeAccountBoundNativeOpenAi
|
|
369
|
+
? visibleCodexAccountSelectors(config)
|
|
370
|
+
: [];
|
|
371
|
+
const observedAccountNativeEntries = [
|
|
372
|
+
...(read.modelsCache?.models ?? []),
|
|
373
|
+
...(onDiskCatalog?.models ?? []).filter(entry =>
|
|
374
|
+
trustedAccountBoundNativeCatalogSlug(entry) !== undefined),
|
|
375
|
+
];
|
|
376
|
+
const accountTargets = new Map(codexAccountNamespaceEntries(config));
|
|
377
|
+
const reserveMainSelectors = accountSelectors.filter(selector =>
|
|
378
|
+
isMainCodexAccountTarget(accountTargets.get(selector) ?? ""));
|
|
379
|
+
// The active file can own a bare source even when the bundled catalog is the build base.
|
|
380
|
+
// A previously clamped qualified projection must not shorten a retained genuine ladder.
|
|
381
|
+
const reserveObservations = [
|
|
382
|
+
...(onDiskCatalog?.models ?? []),
|
|
383
|
+
...(read.modelsCache?.models ?? []),
|
|
384
|
+
...(catalog.models ?? []),
|
|
385
|
+
];
|
|
386
|
+
const retainedReserve = onDiskCatalog?.[RESERVE_SOURCE_CATALOG_FIELD];
|
|
387
|
+
const retainedReserveSource = retainedReserve && typeof retainedReserve === "object" && !Array.isArray(retainedReserve)
|
|
388
|
+
? observedReserveCatalogSource([retainedReserve as RawEntry], [])
|
|
389
|
+
: null;
|
|
390
|
+
const observedReserveSource = observedReserveCatalogSource(
|
|
391
|
+
// Cache invalidation carries historical bare observations alongside emitted models.
|
|
392
|
+
// Only unmarked observations are fresh enough to supersede the retained source.
|
|
393
|
+
reserveObservations.filter(entry => entry.slug === NATIVE_RESERVE_MODEL
|
|
394
|
+
&& entry.opencodex_account_observed_native === undefined), reserveMainSelectors,
|
|
395
|
+
) ?? retainedReserveSource ?? observedReserveCatalogSource(reserveObservations, reserveMainSelectors);
|
|
396
|
+
// This root is read only by OCX. Upstream ModelsResponse ignores unknown root fields.
|
|
397
|
+
// Retain before final runtime clamping: an omitted row must not turn into Luna next sync.
|
|
398
|
+
if (observedReserveSource) catalog[RESERVE_SOURCE_CATALOG_FIELD] = structuredClone(observedReserveSource);
|
|
399
|
+
else delete catalog[RESERVE_SOURCE_CATALOG_FIELD];
|
|
400
|
+
const lunaSource = upstreamNativeEntry(RESERVE_LUNA_METADATA_SOURCE);
|
|
401
|
+
const reserve = createReserveCatalogProjection(
|
|
402
|
+
config,
|
|
403
|
+
reserveMainSelectors,
|
|
404
|
+
observedReserveSource,
|
|
405
|
+
lunaSource ? finishUpstreamNativeEntry(lunaSource, 9, openaiContextCap) : null,
|
|
406
|
+
);
|
|
407
|
+
const accountNativeSlugsBySelector = accountSelectors.length > 0
|
|
408
|
+
? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
|
|
409
|
+
const target = accountTargets.get(selector);
|
|
410
|
+
const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
|
|
411
|
+
return [selector, slugs.filter(slug => (
|
|
412
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
|
|
413
|
+
|| (accountId !== undefined
|
|
414
|
+
&& codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
|
|
415
|
+
))] as const;
|
|
416
|
+
}))
|
|
417
|
+
: new Map<string, readonly string[]>();
|
|
418
|
+
const accountNativeSlugs = accountSelectors.length > 0
|
|
419
|
+
? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))]
|
|
420
|
+
: [];
|
|
421
|
+
// Unknown account-native ids have no safe bare/global identity. They are only projected through
|
|
422
|
+
// the selector map above; the no-selector catalog remains the static native/API-key surface.
|
|
423
|
+
const observedNativeSlugs: string[] = [];
|
|
424
|
+
const wsEnabled = websocketsEnabled(config);
|
|
425
|
+
const multiAgentV2Enabled = isMultiAgentV2Enabled();
|
|
426
|
+
const goEntries = buildCatalogEntriesFromObservedState({
|
|
427
|
+
template: template ? JSON.parse(JSON.stringify(template)) : null,
|
|
428
|
+
gptSlugs: [],
|
|
429
|
+
goModels: orderedGoModels,
|
|
430
|
+
featured,
|
|
431
|
+
modelPickerOrder,
|
|
432
|
+
wsEnabled,
|
|
433
|
+
multiAgentMode,
|
|
434
|
+
exactComboSlugs,
|
|
435
|
+
accountSelectors,
|
|
436
|
+
suppressedBareNativeSlugs,
|
|
437
|
+
disabledNativeAccountSlugs: new Set(),
|
|
438
|
+
multiAgentV2Enabled,
|
|
439
|
+
openaiContextCap,
|
|
440
|
+
});
|
|
441
|
+
// Keep genuine native entries (gpt-*, codex-*) with their real per-model fields and append
|
|
442
|
+
// routed providers as namespaced slugs. Cursor and other adopted providers can expose model ids
|
|
443
|
+
// like `gpt-5.5`; those must not delete the native OpenAI/Codex base row.
|
|
444
|
+
const baselineCatalog = readCatalogBackup(catalogPath);
|
|
445
|
+
const baseline = readNativeBaseline(catalogPath);
|
|
446
|
+
const gatheredProviderNames = new Set(
|
|
447
|
+
Object.entries(config.providers ?? {})
|
|
448
|
+
.filter(([, prov]) => prov.disabled !== true)
|
|
449
|
+
.map(([name]) => name),
|
|
450
|
+
);
|
|
451
|
+
const degradedProviderNames = new Set(
|
|
452
|
+
providerModelOutcomes
|
|
453
|
+
.filter(outcome => outcome.state === "degraded")
|
|
454
|
+
.map(outcome => outcome.provider),
|
|
455
|
+
);
|
|
456
|
+
const selectedModelsByProvider = new Map<string, ReadonlySet<string>>(
|
|
457
|
+
Object.entries(config.providers ?? {}).flatMap(([name, provider]) => (
|
|
458
|
+
provider.disabled !== true
|
|
459
|
+
&& Array.isArray(provider.selectedModels)
|
|
460
|
+
&& provider.selectedModels.length > 0
|
|
461
|
+
? [[name, new Set(provider.selectedModels)] as const]
|
|
462
|
+
: []
|
|
463
|
+
)),
|
|
464
|
+
);
|
|
465
|
+
// Central WS capability override on the FINAL on-disk catalog (the file Codex reads). Applies to
|
|
466
|
+
// native AND routed so the advertised flag matches the implemented endpoint (phase 120.4) and a
|
|
467
|
+
// native template can never leak supports_websockets while the flag is off.
|
|
468
|
+
// #636: when the user only configured non-OpenAI providers (e.g. kimi), do not advertise
|
|
469
|
+
// bare gpt-* rows that hard-404 via NoEnabledOpenAiProviderError. Keep natives when no
|
|
470
|
+
// providers are configured yet (fresh install / catalog bootstrap tests).
|
|
471
|
+
const accountBoundEntries = includeAccountBoundNativeOpenAi && accountSelectors.length > 0
|
|
472
|
+
? buildCatalogEntriesFromObservedState({
|
|
473
|
+
template: template ? JSON.parse(JSON.stringify(template)) : null,
|
|
474
|
+
gptSlugs: availableAccountNativeSlugs,
|
|
475
|
+
goModels: [],
|
|
476
|
+
featured,
|
|
477
|
+
wsEnabled,
|
|
478
|
+
multiAgentMode,
|
|
479
|
+
exactComboSlugs,
|
|
480
|
+
accountSelectors,
|
|
481
|
+
suppressedBareNativeSlugs,
|
|
482
|
+
disabledNativeAccountSlugs: new Set([...disabledNativeSlugs(config)].filter(slug => suppressedBareNativeSlugs.has(slug))),
|
|
483
|
+
multiAgentV2Enabled,
|
|
484
|
+
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
|
|
485
|
+
openaiContextCap,
|
|
486
|
+
accountNativeSlugs,
|
|
487
|
+
accountNativeSlugsBySelector,
|
|
488
|
+
reserve,
|
|
489
|
+
}).filter(entry => trustedAccountBoundNativeCatalogSlug(entry) !== undefined)
|
|
490
|
+
: [];
|
|
491
|
+
catalog.models = mergeCatalogEntriesFromObservedState({
|
|
492
|
+
modelPickerOrder,
|
|
493
|
+
accountSelectors,
|
|
494
|
+
catalogModels: catalogModelsForMerge,
|
|
495
|
+
baselineCatalogModels: baselineCatalog?.models ?? [],
|
|
496
|
+
routedEntries: goEntries,
|
|
497
|
+
baseline,
|
|
498
|
+
featured,
|
|
499
|
+
wsEnabled,
|
|
500
|
+
template,
|
|
501
|
+
disabledModels: new Set(config.disabledModels ?? []),
|
|
502
|
+
selectedModelsByProvider,
|
|
503
|
+
gatheredProviderNames,
|
|
504
|
+
pendingProviderNames: pendingModelSelectionProviders(config),
|
|
505
|
+
degradedProviderNames,
|
|
506
|
+
legacyCustomModelSlugs: legacyCustomModelCatalogSlugs(config),
|
|
507
|
+
multiAgentMode,
|
|
508
|
+
multiAgentV2Enabled,
|
|
509
|
+
keepNativeChatGptOnV1: config.keepNativeChatGptOnV1 === true,
|
|
510
|
+
exactComboSlugs,
|
|
511
|
+
hasPhysicalComboProvider,
|
|
512
|
+
includeNativeOpenAi,
|
|
513
|
+
accountBoundEntries,
|
|
514
|
+
suppressedBareNativeSlugs,
|
|
515
|
+
openaiContextCap,
|
|
516
|
+
nativeDisplayNames: config.providers[OPENAI_CODEX_PROVIDER_ID]?.modelDisplayNames,
|
|
517
|
+
policy: {
|
|
518
|
+
...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
|
|
519
|
+
nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
|
|
520
|
+
warningPolicy: "emit",
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
clampCatalogModelsToCodexSupport(catalog.models);
|
|
524
|
+
finalizeAutoReviewModelOverride(catalog.models, catalogModelsForMerge, config);
|
|
525
|
+
|
|
526
|
+
const added = goEntries.length + accountBoundEntries.length;
|
|
527
|
+
const content = `${JSON.stringify(catalog, null, 2)}\n`;
|
|
528
|
+
// A byte-identical rewrite is not a catalog change, but every mtime-keyed reader
|
|
529
|
+
// has to treat it as one. The app-server staleness classifier (#857) is the one
|
|
530
|
+
// that matters: it compares this file's mtime against each running Codex's start
|
|
531
|
+
// time, so an ordinary `ocx start` — or any dashboard action that re-syncs an
|
|
532
|
+
// unchanged model set — marked every already-running Codex as holding an outdated
|
|
533
|
+
// in-memory catalog. Since #1407 that verdict silences opencodex's own model
|
|
534
|
+
// guidance entirely (no preferred model, no roster) for the rest of that Codex's
|
|
535
|
+
// lifetime, so a configured injectionModel stops reaching the session even though
|
|
536
|
+
// nothing about the catalog changed. Skipping the no-op write keeps both the mtime
|
|
537
|
+
// and `catalogWritten` honest; `added` still reports the routed rows the catalog
|
|
538
|
+
// carries, because they are on disk either way.
|
|
539
|
+
const onDiskBytes = currentCatalogFileContent(catalogPath);
|
|
540
|
+
if (onDiskBytes !== null && onDiskBytes.equals(Buffer.from(content, "utf8"))) {
|
|
541
|
+
return { added, path: catalogPath, catalogWritten: false, comboOmissions };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
replaceActiveCodexCatalog(permit, owningCodexHome, {
|
|
545
|
+
path: catalogPath,
|
|
546
|
+
content,
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
added,
|
|
550
|
+
path: catalogPath,
|
|
551
|
+
catalogWritten: true,
|
|
552
|
+
comboOmissions,
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export async function syncCatalogModels(
|
|
557
|
+
config: OcxConfig,
|
|
558
|
+
options?: CodexCatalogSyncOptions,
|
|
559
|
+
): Promise<RetainedCatalogSyncResult> {
|
|
560
|
+
if (pendingModelSelectionProviders(config).size) {
|
|
561
|
+
const { resolvePendingInitialModelSelection } = await import("../../providers/initial-model-selection-runtime");
|
|
562
|
+
await resolvePendingInitialModelSelection(config);
|
|
563
|
+
}
|
|
564
|
+
const owningCodexHome = getCodexHome();
|
|
565
|
+
const preflightRead = readRetainedCatalogSync(config);
|
|
566
|
+
if (preflightRead === null) {
|
|
567
|
+
return {
|
|
568
|
+
added: 0,
|
|
569
|
+
path: readCodexCatalogPath(),
|
|
570
|
+
catalogWritten: false,
|
|
571
|
+
comboOmissions: [],
|
|
572
|
+
refreshOutcome: "refused",
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const comboOmissions: ComboCatalogOmission[] = [];
|
|
577
|
+
const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = [];
|
|
578
|
+
// Settle the bundled template, then baseline, and only then await. Reading it
|
|
579
|
+
// here makes the memo ours before anyone else can move it, so a bundled swap
|
|
580
|
+
// during the await is an outside change rather than our own side effect.
|
|
581
|
+
//
|
|
582
|
+
// The persisted runtime selection is covered by the filesystem evidence above
|
|
583
|
+
// rather than by a process epoch; see `retainedCatalogProcessEvidence` for why
|
|
584
|
+
// the in-memory runtime memo cannot be baselined honestly from this path.
|
|
585
|
+
loadBundledCodexCatalog();
|
|
586
|
+
const prepared: RetainedCatalogSyncRead = {
|
|
587
|
+
...preflightRead,
|
|
588
|
+
evidence: retainedCatalogSyncEvidence(config, preflightRead.catalogPath, preflightRead.catalog),
|
|
589
|
+
processEvidence: retainedCatalogProcessEvidence(),
|
|
590
|
+
};
|
|
591
|
+
const [goModels, modelEntitlements] = await Promise.all([
|
|
592
|
+
gatherRoutedModels(config, {
|
|
593
|
+
comboOmissions,
|
|
594
|
+
providerModelOutcomes,
|
|
595
|
+
}),
|
|
596
|
+
resolveCodexModelEntitlements(config),
|
|
597
|
+
]);
|
|
598
|
+
const committed = withCatalogWriteSerialization(owningCodexHome, permit => {
|
|
599
|
+
// Desired state can flip OFF during the provider await above. The catalog
|
|
600
|
+
// evidence revalidation below cannot see that — intent lives in our config,
|
|
601
|
+
// not in the catalog files — so the policy is re-read here, under K, right
|
|
602
|
+
// before the only write. A lost race becomes the discriminated skip instead
|
|
603
|
+
// of a routed catalog/cache surviving a completed disable. An explicit
|
|
604
|
+
// catalog-only sync opts out of that gate: the user asked for a refresh even
|
|
605
|
+
// when injection is OFF, and the toggle only protects config/history writes.
|
|
606
|
+
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) {
|
|
607
|
+
return {
|
|
608
|
+
added: 0,
|
|
609
|
+
path: prepared.catalogPath,
|
|
610
|
+
catalogWritten: false,
|
|
611
|
+
comboOmissions,
|
|
612
|
+
skippedReason: "desired_disabled" as const,
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const current = revalidateRetainedCatalogSync(config, prepared);
|
|
616
|
+
if (current === null) return null;
|
|
617
|
+
if (!isCodexModelEntitlementSnapshotCurrent(modelEntitlements)) return null;
|
|
618
|
+
return writeRetainedCatalogSync({
|
|
619
|
+
config,
|
|
620
|
+
goModels,
|
|
621
|
+
providerModelOutcomes,
|
|
622
|
+
comboOmissions,
|
|
623
|
+
read: current,
|
|
624
|
+
permit,
|
|
625
|
+
owningCodexHome,
|
|
626
|
+
modelEntitlements,
|
|
627
|
+
});
|
|
628
|
+
});
|
|
629
|
+
if (committed.kind === "completed" && committed.value !== null) {
|
|
630
|
+
return {
|
|
631
|
+
...committed.value,
|
|
632
|
+
refreshOutcome: committed.value.skippedReason ? "refused" : "committed",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
return {
|
|
636
|
+
added: 0,
|
|
637
|
+
path: prepared.catalogPath,
|
|
638
|
+
catalogWritten: false,
|
|
639
|
+
comboOmissions,
|
|
640
|
+
refreshOutcome: "refused",
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
export function invalidateCodexModelsCacheWithPermit(
|
|
645
|
+
permit: CatalogWritePermit,
|
|
646
|
+
owningCodexHome: string,
|
|
647
|
+
options?: CodexCatalogSyncOptions,
|
|
648
|
+
): boolean {
|
|
649
|
+
try {
|
|
650
|
+
// This permit is a REACQUISITION: refreshCodexModelCatalog's commit released
|
|
651
|
+
// K before this rewrite runs, so the commit-path desired-state check cannot
|
|
652
|
+
// cover it. A disable landing in that gap must not be overwritten by a
|
|
653
|
+
// routed cache write — re-read intent under this permit, same as the commit.
|
|
654
|
+
// The catalog-only sync override applies here too so an explicit refresh
|
|
655
|
+
// keeps the cache consistent with the catalog it just wrote.
|
|
656
|
+
if (!shouldSyncCodexOnStart(loadConfig()) && options?.allowWhenDesiredDisabled !== true) return false;
|
|
657
|
+
const catalogPath = readCodexCatalogPathForHome(owningCodexHome);
|
|
658
|
+
if (!existsSync(catalogPath)) return false;
|
|
659
|
+
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
|
660
|
+
const models = catalog.models ?? catalog;
|
|
661
|
+
const cachePath = join(owningCodexHome, "models_cache.json");
|
|
662
|
+
const currentCache = readCatalog(cachePath);
|
|
663
|
+
const existingSlugs = new Set(models.flatMap((entry: RawEntry) =>
|
|
664
|
+
typeof entry.slug === "string" ? [entry.slug] : []));
|
|
665
|
+
const currentConfig = loadConfig();
|
|
666
|
+
const mainSelectors = visibleCodexAccountSelectors(currentConfig).filter(selector => {
|
|
667
|
+
const target = new Map(codexAccountNamespaceEntries(currentConfig)).get(selector);
|
|
668
|
+
return isMainCodexAccountTarget(target ?? "");
|
|
669
|
+
});
|
|
670
|
+
const observedAccountModels = observedAccountBoundNativeEntries(currentCache?.models ?? [])
|
|
671
|
+
.filter(entry => {
|
|
672
|
+
const slug = typeof entry.slug === "string" ? entry.slug : "";
|
|
673
|
+
return !existingSlugs.has(slug);
|
|
674
|
+
})
|
|
675
|
+
.map(entry => ({
|
|
676
|
+
...entry,
|
|
677
|
+
// Keep the observation in Codex's cache without advertising a new bare picker row. The
|
|
678
|
+
// next OpenCodex catalog sync consumes this marker and creates only selector-qualified
|
|
679
|
+
// rows for the currently configured public account selectors.
|
|
680
|
+
visibility: "hide",
|
|
681
|
+
opencodex_account_observed_native: true,
|
|
682
|
+
opencodex_account_observed_selectors: mainSelectors,
|
|
683
|
+
}));
|
|
684
|
+
const wrapper = {
|
|
685
|
+
fetched_at: "2000-01-01T00:00:00Z",
|
|
686
|
+
client_version: "0.0.0",
|
|
687
|
+
models: [...models, ...observedAccountModels],
|
|
688
|
+
};
|
|
689
|
+
replaceCodexModelsCache(permit, owningCodexHome, {
|
|
690
|
+
path: cachePath,
|
|
691
|
+
content: `${JSON.stringify(wrapper, null, 2)}\n`,
|
|
692
|
+
});
|
|
693
|
+
return true;
|
|
694
|
+
} catch {
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export function invalidateCodexModelsCache(options?: CodexCatalogSyncOptions): boolean {
|
|
700
|
+
const owningCodexHome = getCodexHome();
|
|
701
|
+
const outcome = withCatalogWriteSerialization(
|
|
702
|
+
owningCodexHome,
|
|
703
|
+
permit => invalidateCodexModelsCacheWithPermit(permit, owningCodexHome, options),
|
|
704
|
+
);
|
|
705
|
+
return outcome.kind === "completed" && outcome.value;
|
|
706
|
+
}
|