@bitkyc08/opencodex 2.48.0-preview.20260908 → 2.49.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/README.md +8 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-BtyONQrZ.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +24 -1
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/auth-api.ts +6 -3
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +75 -7
- package/src/codex/catalog/provider-fetch.ts +73 -9
- package/src/codex/inject.ts +127 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota.ts +18 -8
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/config.ts +1 -0
- package/src/generated/compatibility-version.json +126 -74
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/registry.ts +139 -0
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/chat-completions.ts +26 -9
- package/src/server/claude-messages.ts +12 -2
- package/src/server/images.ts +1 -0
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +1 -0
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/provider-routes.ts +24 -0
- package/src/server/management-api.ts +14 -0
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/core.ts +141 -19
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +12 -0
- package/src/service.ts +36 -2
- package/src/storage/cleanup.ts +77 -45
- package/src/types/config.ts +7 -1
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B6EFoatP.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { createHmac, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import {
|
|
3
3
|
CodexCredentialGenerationConflictError,
|
|
4
4
|
CodexCredentialRefreshLockTimeoutError,
|
|
5
5
|
CodexCredentialRefreshBusyError,
|
|
6
6
|
CodexCredentialRefreshStaleError,
|
|
7
|
+
getCodexAccountCredential,
|
|
7
8
|
getValidCodexToken,
|
|
8
9
|
isCodexAccountGenerationLive,
|
|
9
10
|
} from "./account-store";
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
isMainAccountTokenLive,
|
|
22
23
|
type NativeMainRefreshDependencies,
|
|
23
24
|
} from "./main-account";
|
|
24
|
-
import { isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
|
|
25
|
+
import { isMainAccountPolicyBindingPending, isNativeMainTrafficBlocked, nativeMainStartupGateSnapshot } from "./native-profile-startup";
|
|
25
26
|
import type { NativeMainStartupBlockReason } from "./native-profile-startup";
|
|
26
27
|
import {
|
|
27
28
|
codexQuotaScopeForModel,
|
|
@@ -49,7 +50,7 @@ import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
|
|
|
49
50
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
50
51
|
import { captureConfigGeneration } from "../lib/state-store-sweeper";
|
|
51
52
|
import { retainedUtf8Bytes } from "../lib/admission";
|
|
52
|
-
import { extractAccountId } from "../oauth/chatgpt";
|
|
53
|
+
import { extractAccountId, extractEmail } from "../oauth/chatgpt";
|
|
53
54
|
import { getMainAccountHardLockStatus, isMainAccountHardLocked } from "./main-account-hard-lock";
|
|
54
55
|
import {
|
|
55
56
|
captureMainAccountIdentityGeneration,
|
|
@@ -62,7 +63,7 @@ import {
|
|
|
62
63
|
} from "./main-account-cache";
|
|
63
64
|
import { CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE, isCodexReserveHelperUnsupported, isCodexReserveRequestEligible } from "./loopback-target";
|
|
64
65
|
import type { DataPlaneAdmission } from "../server/auth-cors";
|
|
65
|
-
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, type MainReserveAuthorization } from "./reserve-availability";
|
|
66
|
+
import { getMainReserveAuthorization, isMainReserveAuthorizationLive, nativeUserIdClaims, type MainReserveAuthorization } from "./reserve-availability";
|
|
66
67
|
import { UpstreamRetryEvidenceError } from "../lib/upstream-retry";
|
|
67
68
|
|
|
68
69
|
const CODEX_AFFINITY_COMPONENT_MAX_BYTES = 512;
|
|
@@ -455,6 +456,54 @@ function callerMatchesObservedMain(headers: Headers): boolean {
|
|
|
455
456
|
return matchesMainQuotaCredential(bearer, effectiveAccountId);
|
|
456
457
|
}
|
|
457
458
|
|
|
459
|
+
/** Constant-time digest comparison so bearer bytes never drive branch timing. */
|
|
460
|
+
function sameCredentialMaterial(a: string, b: string): boolean {
|
|
461
|
+
return timingSafeEqual(createHash("sha256").update(a).digest(), createHash("sha256").update(b).digest());
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* The early-cooldown caller-main fallback must not resurrect the subscription that is cooling
|
|
466
|
+
* down. Fail closed on ambiguity: an unreadable caller identity cannot be distinguished from the
|
|
467
|
+
* cooled account. A distinct workspace account id is always safe; an exact materialized
|
|
468
|
+
* bearer + account tuple marks the same subscription. Beyond that, the stable native user id is
|
|
469
|
+
* the strongest available evidence: it survives an email change and a token rotation, and it
|
|
470
|
+
* separates members who share one workspace account id even when neither credential carries an
|
|
471
|
+
* email. Email remains the fallback when no comparable user id exists on both sides. Coexisting
|
|
472
|
+
* personal/business registrations with the same email and account id over-deny during the
|
|
473
|
+
* cooldown — the safe direction.
|
|
474
|
+
*/
|
|
475
|
+
function callerIsCooledPoolAccount(headers: Headers, config: OcxConfig, accountId: string): boolean {
|
|
476
|
+
const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
477
|
+
if (!bearer) return true;
|
|
478
|
+
const callerAccountId = headers.get("chatgpt-account-id") ?? extractAccountId(undefined, bearer);
|
|
479
|
+
if (callerAccountId === undefined) return true;
|
|
480
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
481
|
+
// No physical-main read to identify the caller: the observed-main equality tag suffices.
|
|
482
|
+
return callerMatchesObservedMain(headers);
|
|
483
|
+
}
|
|
484
|
+
const stored = getCodexAccountCredential(accountId);
|
|
485
|
+
const entry = config.codexAccounts?.find(account => account.id === accountId);
|
|
486
|
+
const cooledAccountId = stored?.chatgptAccountId || entry?.chatgptAccountId;
|
|
487
|
+
if (!cooledAccountId) return true;
|
|
488
|
+
if (cooledAccountId !== callerAccountId) return false;
|
|
489
|
+
if (stored?.accessToken && sameCredentialMaterial(bearer, stored.accessToken)) return true;
|
|
490
|
+
// Same namespace on both sides, never `sub`: this is the ChatGPT per-user identity the reserve
|
|
491
|
+
// path already trusts. A credential whose own two encodings of it disagree cannot identify
|
|
492
|
+
// anyone, so it fails closed even when the disagreement is on the stored side.
|
|
493
|
+
const callerUser = nativeUserIdClaims(bearer);
|
|
494
|
+
const cooledUser = stored?.accessToken
|
|
495
|
+
? nativeUserIdClaims(stored.accessToken)
|
|
496
|
+
: { userId: undefined, conflict: false };
|
|
497
|
+
if (callerUser.conflict || cooledUser.conflict) return true;
|
|
498
|
+
if (callerUser.userId !== undefined && cooledUser.userId !== undefined) {
|
|
499
|
+
return callerUser.userId === cooledUser.userId;
|
|
500
|
+
}
|
|
501
|
+
const callerEmail = extractEmail(undefined, bearer)?.trim().toLowerCase() || undefined;
|
|
502
|
+
const cooledEmail = entry?.email?.trim().toLowerCase() || undefined;
|
|
503
|
+
if (callerEmail !== undefined && cooledEmail !== undefined) return callerEmail === cooledEmail;
|
|
504
|
+
return true;
|
|
505
|
+
}
|
|
506
|
+
|
|
458
507
|
function captureObservedMainWriter(): MainQuotaWriter | undefined {
|
|
459
508
|
const identityKey = getObservedMainQuotaIdentityKey();
|
|
460
509
|
return identityKey === undefined ? undefined : {
|
|
@@ -598,20 +647,31 @@ export async function resolveCodexAuthContext(
|
|
|
598
647
|
throw new CodexReserveUnavailableError();
|
|
599
648
|
}
|
|
600
649
|
const fixedAccountId = reserve ? MAIN_CODEX_ACCOUNT_ID : options.accountId;
|
|
601
|
-
const
|
|
650
|
+
const requestOwnedMainPinCandidate = requestScopedMainCredential
|
|
602
651
|
&& fixedAccountId === undefined
|
|
603
652
|
&& config.activeCodexAccountPinned === MAIN_CODEX_ACCOUNT_ID
|
|
604
653
|
&& isEffectiveCodexAccountPinned(config)
|
|
605
654
|
&& !policy.pausedCodexAccountIds?.includes(MAIN_CODEX_ACCOUNT_ID)
|
|
606
|
-
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy))
|
|
607
655
|
&& requestOwnedMainPinHasQuotaHeadroom(config);
|
|
656
|
+
// During an owned startup, equality cannot be established until recovery and the
|
|
657
|
+
// memory-only policy binding finish. This read-only fence never probes a foreign home.
|
|
658
|
+
if (policy.codexMainAccountHardLock === true && requestOwnedMainPinCandidate && isMainAccountPolicyBindingPending()) {
|
|
659
|
+
throw new CodexMainProfileDrainingError();
|
|
660
|
+
}
|
|
661
|
+
const preserveRequestOwnedMainPin = requestOwnedMainPinCandidate
|
|
662
|
+
&& !(callerMatchesObservedMain(headers) && isMainAccountHardLocked(policy));
|
|
608
663
|
if (fixedAccountId !== undefined && options.excludeAccountId !== undefined) {
|
|
609
664
|
throw new Error("Codex auth context cannot select and exclude an account simultaneously");
|
|
610
665
|
}
|
|
611
666
|
const resolveCallerOwnedMainContext = async (): Promise<CodexAuthContext> => {
|
|
612
|
-
if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
613
667
|
const substituteStoredMain = options.substituteMainCredentialForDirect === true;
|
|
668
|
+
// An internal route change can strip the admission bearer before this point.
|
|
669
|
+
// Trusted substitution still has to claim and validate stored main below.
|
|
670
|
+
if (!substituteStoredMain && !hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
|
|
614
671
|
if (!substituteStoredMain) {
|
|
672
|
+
if (policy.codexMainAccountHardLock === true && isMainAccountPolicyBindingPending()) {
|
|
673
|
+
throw new CodexMainProfileDrainingError();
|
|
674
|
+
}
|
|
615
675
|
if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
|
|
616
676
|
if (reserve) {
|
|
617
677
|
const selected = materializeCodexUpstreamAuth(headers, { kind: "main", accountId: null }, { config: policy });
|
|
@@ -885,6 +945,14 @@ export async function resolveCodexAuthContext(
|
|
|
885
945
|
? tryAcquireCodexQuotaScopeProbeLease(accountId, probeQuotaScope) ?? undefined
|
|
886
946
|
: tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
|
|
887
947
|
if (!probeLeaseId) {
|
|
948
|
+
// The selector can retain the configured Pool account when no stored
|
|
949
|
+
// alternate is eligible. A validated caller may still serve this request,
|
|
950
|
+
// just as it can after an upstream rejection, without changing Pool state.
|
|
951
|
+
if (requestScopedMainCredential && fixedAccountId === undefined
|
|
952
|
+
&& options.excludeAccountId !== MAIN_CODEX_ACCOUNT_ID
|
|
953
|
+
&& !callerIsCooledPoolAccount(headers, config, accountId)) {
|
|
954
|
+
return await resolveCallerOwnedMainContext();
|
|
955
|
+
}
|
|
888
956
|
throw new CodexAccountCooldownError(accountId, cooldownUntil, cooldown?.cooldownSource, cooldown?.quotaScope);
|
|
889
957
|
}
|
|
890
958
|
}
|
|
@@ -52,6 +52,8 @@ import { CODEX_GPT5_IDENTITY_LINE } from "../../adapters/identity";
|
|
|
52
52
|
import { filterCursorConfiguredModelsByLiveDiscovery } from "../../adapters/cursor/discovery";
|
|
53
53
|
import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
|
|
54
54
|
import { recordLiveCursorClaudeModels, recordLiveCursorMaxModeModels } from "../../adapters/cursor/catalog";
|
|
55
|
+
import { fetchQoderModels } from "../../adapters/qoder/live-models";
|
|
56
|
+
import { resolveQoderProfile } from "../../adapters/qoder/profiles";
|
|
55
57
|
import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
56
58
|
import {
|
|
57
59
|
COMBO_NAMESPACE,
|
|
@@ -952,16 +954,21 @@ function comboMemberVendorMetadata(provider: string, modelId: string): ModelMeta
|
|
|
952
954
|
*/
|
|
953
955
|
function vendorMetadataComboFallback(target: { provider: string; model: string }): ComboCatalogMemberFallback | undefined {
|
|
954
956
|
const metadataProvider = resolveMetadataProvider(target.provider);
|
|
955
|
-
|
|
957
|
+
// Custom OpenAI-compatible routes commonly retain the canonical OpenAI model id
|
|
958
|
+
// while using a provider name that has no metadata alias. Reuse only its effort
|
|
959
|
+
// ladder below; context/modality rows remain provider-owned.
|
|
960
|
+
const metadata = metadataProvider
|
|
961
|
+
? comboMemberVendorMetadata(metadataProvider, target.model)
|
|
962
|
+
: comboMemberVendorMetadata("openai", target.model);
|
|
956
963
|
if (!metadata) return undefined;
|
|
957
964
|
return {
|
|
958
|
-
...(typeof metadata.contextWindow === "number" && metadata.contextWindow > 0
|
|
965
|
+
...(metadataProvider && typeof metadata.contextWindow === "number" && metadata.contextWindow > 0
|
|
959
966
|
? { contextWindow: metadata.contextWindow }
|
|
960
967
|
: {}),
|
|
961
|
-
...(typeof metadata.maxTokens === "number" && metadata.maxTokens > 0
|
|
968
|
+
...(metadataProvider && typeof metadata.maxTokens === "number" && metadata.maxTokens > 0
|
|
962
969
|
? { maxOutputTokens: metadata.maxTokens }
|
|
963
970
|
: {}),
|
|
964
|
-
...(Array.isArray(metadata.input) && metadata.input.length > 0
|
|
971
|
+
...(metadataProvider && Array.isArray(metadata.input) && metadata.input.length > 0
|
|
965
972
|
? { inputModalities: [...metadata.input] }
|
|
966
973
|
: {}),
|
|
967
974
|
...(metadata.reasoning === true ? { reasoningEfforts: [...ROUTED_COMBO_MEMBER_REASONING_EFFORTS] } : {}),
|
|
@@ -1038,15 +1045,21 @@ export function resolveComboCatalogMember(
|
|
|
1038
1045
|
&& typeof existing.contextWindow === "number"
|
|
1039
1046
|
&& existing.contextWindow > 0
|
|
1040
1047
|
) {
|
|
1041
|
-
|
|
1048
|
+
// Live discovery can explicitly say text-only even when configured routing
|
|
1049
|
+
// supplies a vision sidecar. Apply the same provider hints used for thin
|
|
1050
|
+
// rows before deriving a combo from this complete row.
|
|
1051
|
+
const hinted = prov && isModelVisionSidecarConsumer(prov, existing.id)
|
|
1052
|
+
? applyProviderConfigHints(target.provider, prov, existing, contextCap, metadataModelIdCaseFold)
|
|
1053
|
+
: existing;
|
|
1054
|
+
const capped = applyProviderContextCap(hinted.contextWindow, contextCap);
|
|
1042
1055
|
if (capped === undefined || capped === existing.contextWindow) {
|
|
1043
|
-
return withFallbackMetadata(
|
|
1056
|
+
return withFallbackMetadata(hinted);
|
|
1044
1057
|
}
|
|
1045
|
-
const maxInput = typeof
|
|
1046
|
-
? Math.min(
|
|
1058
|
+
const maxInput = typeof hinted.maxInputTokens === "number" && hinted.maxInputTokens > 0
|
|
1059
|
+
? Math.min(hinted.maxInputTokens, capped)
|
|
1047
1060
|
: Math.min(fallback?.maxInputTokens ?? capped, capped);
|
|
1048
1061
|
return withFallbackMetadata({
|
|
1049
|
-
...
|
|
1062
|
+
...hinted,
|
|
1050
1063
|
contextWindow: capped,
|
|
1051
1064
|
maxInputTokens: maxInput,
|
|
1052
1065
|
contextCap,
|
|
@@ -1412,6 +1425,13 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
|
|
|
1412
1425
|
// supplying a recognized field changes behavior (#1797).
|
|
1413
1426
|
plainRecord(item.meta)?.n_ctx,
|
|
1414
1427
|
plainRecord(item.meta)?.n_ctx_train,
|
|
1428
|
+
// A chained OpenCodex hub (and other re-serving gateways) reports the per-model
|
|
1429
|
+
// window on the same capability record this function already reads for
|
|
1430
|
+
// `max_output_tokens` below (#4032). Without it every routed row fell through to
|
|
1431
|
+
// the 128k compatibility floor in parsing.ts while local forward rows kept their
|
|
1432
|
+
// real values. Appended after the recognized fields for the same reason as the
|
|
1433
|
+
// llama.cpp entries above: no provider that already resolves changes behavior.
|
|
1434
|
+
capabilityRecord?.context_length,
|
|
1415
1435
|
);
|
|
1416
1436
|
const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
|
|
1417
1437
|
const maxOutputTokens = positiveSafeInteger(
|
|
@@ -1582,6 +1602,50 @@ async function fetchProviderModelsWithAuth(
|
|
|
1582
1602
|
? [...models, vertexDefaultSeed]
|
|
1583
1603
|
: models
|
|
1584
1604
|
);
|
|
1605
|
+
if (prov.adapter === "qoder") {
|
|
1606
|
+
if (!apiKey) return observed(configured, "degraded");
|
|
1607
|
+
const profile = resolveQoderProfile(prov.baseUrl);
|
|
1608
|
+
if (!profile) return observed(configured, "degraded");
|
|
1609
|
+
// Qoder's model list is entitlement-specific. Bind cache reads/writes to an irreversible PAT
|
|
1610
|
+
// fingerprint so an account switch cannot observe another account's roster, even if a caller
|
|
1611
|
+
// bypasses the normal config mutation path that clears provider caches.
|
|
1612
|
+
const authorityIdentity = createHash("sha256").update(apiKey).digest("hex");
|
|
1613
|
+
const fresh = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
|
|
1614
|
+
if (fresh) {
|
|
1615
|
+
return observed(withConfiguredRetention(
|
|
1616
|
+
applyConfigHintsToCachedModels(name, prov, fresh, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
|
|
1617
|
+
), "authoritative");
|
|
1618
|
+
}
|
|
1619
|
+
const scopedStale = getStaleCached(name, authorityIdentity);
|
|
1620
|
+
if (isModelsFetchCoolingDown(name) && scopedStale) {
|
|
1621
|
+
return observed(withConfiguredRetention(
|
|
1622
|
+
applyConfigHintsToCachedModels(name, prov, scopedStale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
|
|
1623
|
+
), "degraded");
|
|
1624
|
+
}
|
|
1625
|
+
const live = await fetchQoderModels(profile, apiKey);
|
|
1626
|
+
if (live.ok) {
|
|
1627
|
+
const discovered = live.models.map(id => ({
|
|
1628
|
+
id,
|
|
1629
|
+
provider: name,
|
|
1630
|
+
...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
|
|
1631
|
+
}));
|
|
1632
|
+
const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
|
|
1633
|
+
if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
|
|
1634
|
+
return observed(withConfiguredRetention(configured), "degraded");
|
|
1635
|
+
}
|
|
1636
|
+
markProviderDiscoveryOk(name, live.models.length);
|
|
1637
|
+
return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
|
|
1638
|
+
}
|
|
1639
|
+
if (isCurrentCacheGeneration()) {
|
|
1640
|
+
markModelsFetchFailure(name);
|
|
1641
|
+
markProviderDiscoveryFailed(name, { reason: "provider" });
|
|
1642
|
+
console.warn(`[opencodex] Qoder model discovery for "${name}" failed [${live.error}]${live.detail ? `: ${live.detail}` : ""}; using stale/static catalog degradation.`);
|
|
1643
|
+
}
|
|
1644
|
+
const stale = getStaleCached(name, authorityIdentity);
|
|
1645
|
+
return observed(withConfiguredRetention(
|
|
1646
|
+
stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
|
|
1647
|
+
), "degraded");
|
|
1648
|
+
}
|
|
1585
1649
|
if (prov.adapter === "cursor") {
|
|
1586
1650
|
if (!apiKey) return observed(configured, "degraded");
|
|
1587
1651
|
// Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
|
package/src/codex/inject.ts
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
resolveEffectiveUserIdentity,
|
|
32
32
|
} from "./user-identity";
|
|
33
33
|
import {
|
|
34
|
+
hasUnverifiedJournalBaseline,
|
|
34
35
|
markJournalInjectedState,
|
|
35
36
|
journaledInjectedOpenaiBaseUrl,
|
|
36
37
|
journaledInjectedRealtimeWsBaseUrl,
|
|
@@ -174,6 +175,8 @@ export interface CodexRoutingTarget {
|
|
|
174
175
|
* and is never weakened by this flag.
|
|
175
176
|
*/
|
|
176
177
|
desktopAuthless?: boolean;
|
|
178
|
+
/** Select the dedicated provider identity so Codex owns compaction locally. */
|
|
179
|
+
clientCompaction?: boolean;
|
|
177
180
|
}
|
|
178
181
|
|
|
179
182
|
function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTarget {
|
|
@@ -197,14 +200,19 @@ function validateCodexRoutingTarget(target: CodexRoutingTarget): CodexRoutingTar
|
|
|
197
200
|
return { ...target, baseUrl: `${parsed.origin}/v1` };
|
|
198
201
|
}
|
|
199
202
|
|
|
200
|
-
/** Provider-table form is used
|
|
203
|
+
/** Provider-table form is used when auth, admission, or compaction policy needs a dedicated provider. */
|
|
201
204
|
function usesProviderTable(target: CodexRoutingTarget): boolean {
|
|
202
|
-
return target.requiresAdmissionToken
|
|
205
|
+
return target.requiresAdmissionToken
|
|
206
|
+
|| target.desktopAuthless === true
|
|
207
|
+
|| target.clientCompaction === true;
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
export function standaloneCodexRoutingTarget(
|
|
206
211
|
port: number,
|
|
207
|
-
config?: Pick<
|
|
212
|
+
config?: Pick<
|
|
213
|
+
OcxConfig,
|
|
214
|
+
"hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless" | "codexClientCompaction"
|
|
215
|
+
>,
|
|
208
216
|
): CodexRoutingTarget {
|
|
209
217
|
const loopback = config?.unauthenticatedLoopbackListener;
|
|
210
218
|
const effectivePort = loopback?.enabled ? loopback.port : port;
|
|
@@ -217,6 +225,9 @@ export function standaloneCodexRoutingTarget(
|
|
|
217
225
|
...(config?.codexDesktopAuthless === true && !requiresAdmissionToken
|
|
218
226
|
? { desktopAuthless: true }
|
|
219
227
|
: {}),
|
|
228
|
+
...(config?.codexClientCompaction === true && !requiresAdmissionToken
|
|
229
|
+
? { clientCompaction: true }
|
|
230
|
+
: {}),
|
|
220
231
|
};
|
|
221
232
|
}
|
|
222
233
|
|
|
@@ -833,7 +844,7 @@ function buildProfileFileForTarget(
|
|
|
833
844
|
const host = new URL(origin).host;
|
|
834
845
|
// Design B (loopback): the reference/fallback file documents the root override form.
|
|
835
846
|
// Non-loopback keeps the legacy provider-table shape (built-in provider cannot carry
|
|
836
|
-
// the x-opencodex-api-key env header);
|
|
847
|
+
// the x-opencodex-api-key env header); explicit Desktop policies share that shape.
|
|
837
848
|
if (!usesProviderTable(target)) {
|
|
838
849
|
const lines = [
|
|
839
850
|
"# OpenCodex proxy fallback config (Design B)",
|
|
@@ -1014,11 +1025,37 @@ export async function injectCodexConfig(
|
|
|
1014
1025
|
? setRootModelCatalogPath(content, catalogPath)
|
|
1015
1026
|
: stripOpencodexCatalogPath(content);
|
|
1016
1027
|
|
|
1017
|
-
// Provider-table form: non-loopback admission
|
|
1018
|
-
const
|
|
1028
|
+
// Provider-table form: non-loopback admission or an explicit Desktop policy.
|
|
1029
|
+
const providerTableMode = usesProviderTable(routingTarget);
|
|
1030
|
+
// Client compaction is the one table form that must not orphan existing threads. It changes
|
|
1031
|
+
// the DEFAULT provider to `opencodex`, but a thread already tagged `openai` keeps resolving
|
|
1032
|
+
// to Codex's built-in entry, and without the root override that entry is api.openai.com —
|
|
1033
|
+
// the thread would resume outside this proxy and outside configured routing. Keeping the
|
|
1034
|
+
// marker-owned root override alongside the table fixes that at the source: codex builds its
|
|
1035
|
+
// provider map as merge_configured_model_providers(built_in_model_providers(openai_base_url),
|
|
1036
|
+
// model_providers), so the override lands on the built-in `openai` entry when the map is
|
|
1037
|
+
// built, independent of which id is the default, and the merge leaves that entry alone for
|
|
1038
|
+
// every id except the two Amazon Bedrock ones. With the managed override in place both
|
|
1039
|
+
// entries point at this proxy. That is a guarantee about the line we own: when the user owns
|
|
1040
|
+
// the root line we inject nothing, and the built-in entry keeps whatever destination they
|
|
1041
|
+
// chose, so an `openai`-tagged thread follows their configuration rather than this proxy.
|
|
1042
|
+
//
|
|
1043
|
+
// Re-tagging history was the alternative and it cannot be made durable: the length-preserving
|
|
1044
|
+
// first-line repair cannot grow "openai" into "opencodex" without pre-existing padding, and
|
|
1045
|
+
// codex re-appends that stale first line whenever it writes git or memory-mode metadata.
|
|
1046
|
+
//
|
|
1047
|
+
// Authless is excluded on purpose: its whole point is a provider that carries
|
|
1048
|
+
// requires_openai_auth = false, and admission-token forms cannot use the root key at all.
|
|
1049
|
+
// Those two forms therefore keep their existing behaviour, forward-tagging resume history with
|
|
1050
|
+
// originals backed up, and that includes the case where a user enables authless and client
|
|
1051
|
+
// compaction together. Only the compaction-only form skips the history unit.
|
|
1052
|
+
const keepRootOverrideAlongsideTable = providerTableMode
|
|
1053
|
+
&& routingTarget.clientCompaction === true
|
|
1054
|
+
&& routingTarget.desktopAuthless !== true
|
|
1055
|
+
&& routingTarget.requiresAdmissionToken !== true;
|
|
1019
1056
|
let keptUserBaseUrl = false;
|
|
1020
1057
|
let keptUserRealtimeWsBaseUrl = false;
|
|
1021
|
-
if (
|
|
1058
|
+
if (providerTableMode) {
|
|
1022
1059
|
// Legacy (non-loopback) injection: the built-in openai provider cannot carry the
|
|
1023
1060
|
// x-opencodex-api-key env header, so keep the opencodex provider table + root re-tag.
|
|
1024
1061
|
// The authless opt-in needs the same table because only a dedicated provider can carry
|
|
@@ -1030,6 +1067,14 @@ export async function injectCodexConfig(
|
|
|
1030
1067
|
content.trimEnd() +
|
|
1031
1068
|
"\n" +
|
|
1032
1069
|
buildProviderTableBlockForTarget(routingTarget, websocketsEnabled(config ?? {}));
|
|
1070
|
+
// 3) Keep existing `openai`-tagged threads reaching the proxy (see above). Ownership rules
|
|
1071
|
+
// are the Design B ones: a user's own root line is never replaced.
|
|
1072
|
+
if (keepRootOverrideAlongsideTable) {
|
|
1073
|
+
content = stripInjectedOpenaiBaseUrl(content);
|
|
1074
|
+
const rootFallback = setRootOpenaiBaseUrlForTarget(content, routingTarget);
|
|
1075
|
+
content = rootFallback.content;
|
|
1076
|
+
keptUserBaseUrl = rootFallback.keptUserBaseUrl;
|
|
1077
|
+
}
|
|
1033
1078
|
} else {
|
|
1034
1079
|
// Design B (loopback): a single root override; codex keeps its native `openai` provider id
|
|
1035
1080
|
// so thread history is never remapped. Any legacy form was already stripped above.
|
|
@@ -1149,6 +1194,24 @@ export async function injectCodexConfig(
|
|
|
1149
1194
|
};
|
|
1150
1195
|
}
|
|
1151
1196
|
|
|
1197
|
+
const journalBaselineIsNative = (): boolean => {
|
|
1198
|
+
// Value evidence survives an app rewrite that removes the ownership comments.
|
|
1199
|
+
const journaledBaseUrl = journaledInjectedOpenaiBaseUrl({ readOnly: true });
|
|
1200
|
+
const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl({ readOnly: true });
|
|
1201
|
+
const looksInjectedByValue =
|
|
1202
|
+
(journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl)
|
|
1203
|
+
|| (journaledRealtimeWsBaseUrl !== null
|
|
1204
|
+
&& rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
|
|
1205
|
+
return !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue;
|
|
1206
|
+
};
|
|
1207
|
+
const readCurrentProfile = (): string | null => existsSync(CODEX_PROFILE_PATH)
|
|
1208
|
+
? readFileSync(CODEX_PROFILE_PATH, "utf-8")
|
|
1209
|
+
: null;
|
|
1210
|
+
const unverifiedJournalMessage = "Codex configuration was not written: the journal has no verified baseline for the current config/profile. Current files and the journal were preserved.";
|
|
1211
|
+
if (!journalBaselineIsNative() && hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) {
|
|
1212
|
+
return { success: false, message: unverifiedJournalMessage };
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1152
1215
|
if (options.validateOnly) {
|
|
1153
1216
|
return {
|
|
1154
1217
|
success: true,
|
|
@@ -1157,31 +1220,29 @@ export async function injectCodexConfig(
|
|
|
1157
1220
|
}
|
|
1158
1221
|
|
|
1159
1222
|
const applyNativeArtifacts = (): void => {
|
|
1160
|
-
// #1798 again: a Codex app rewrite keeps values and drops the ownership comments, so
|
|
1161
|
-
// marker evidence alone would classify our own routed config as the user's native
|
|
1162
|
-
// baseline and replace the real original snapshot. Value evidence from the journal
|
|
1163
|
-
// (the URLs the last injection recorded writing) blocks that misclassification.
|
|
1164
|
-
const journaledBaseUrl = journaledInjectedOpenaiBaseUrl();
|
|
1165
|
-
const journaledRealtimeWsBaseUrl = journaledInjectedRealtimeWsBaseUrl();
|
|
1166
|
-
const looksInjectedByValue =
|
|
1167
|
-
(journaledBaseUrl !== null && rootTomlString(rawContent, "openai_base_url") === journaledBaseUrl)
|
|
1168
|
-
|| (journaledRealtimeWsBaseUrl !== null
|
|
1169
|
-
&& rootTomlString(rawContent, REALTIME_WS_BASE_URL_KEY) === journaledRealtimeWsBaseUrl);
|
|
1170
1223
|
writeJournal({
|
|
1171
|
-
currentStateIsNative:
|
|
1224
|
+
currentStateIsNative: journalBaselineIsNative(),
|
|
1172
1225
|
configContent: baselineContent,
|
|
1173
1226
|
owner: options.journalOwner,
|
|
1174
1227
|
});
|
|
1228
|
+
// A native snapshot may have been refreshed above. An older hashless routed snapshot
|
|
1229
|
+
// must not gain the new injection's hash and later overwrite preserved user edits.
|
|
1230
|
+
if (hasUnverifiedJournalBaseline(baselineContent, readCurrentProfile())) throw new Error(unverifiedJournalMessage);
|
|
1175
1231
|
atomicWriteFile(CODEX_CONFIG_PATH, content);
|
|
1176
1232
|
atomicWriteFile(CODEX_PROFILE_PATH, profileContent);
|
|
1177
1233
|
markJournalInjectedState(content, profileContent, {
|
|
1178
|
-
// A root override is ours
|
|
1179
|
-
|
|
1234
|
+
// A root override is ours whenever we wrote one and no user-owned value won. That is
|
|
1235
|
+
// loopback Design B, and now also the client-compaction form, which keeps the same
|
|
1236
|
+
// marker-owned root line beside its provider table. Journaling it matters because the
|
|
1237
|
+
// marker comment is not durable: the Codex app can reserialize config.toml and drop
|
|
1238
|
+
// comments, and restore then has only the journaled value to tell our line from a user's
|
|
1239
|
+
// (#1798). The other table forms never write the key, so they still record null.
|
|
1240
|
+
injectedOpenaiBaseUrl: (providerTableMode && !keepRootOverrideAlongsideTable) || keptUserBaseUrl
|
|
1180
1241
|
? null
|
|
1181
1242
|
: rootTomlString(content, "openai_base_url"),
|
|
1182
1243
|
// The sideband override is ours only when we wrote it this pass (never in legacy mode,
|
|
1183
1244
|
// never when the user owns either key).
|
|
1184
|
-
injectedRealtimeWsBaseUrl:
|
|
1245
|
+
injectedRealtimeWsBaseUrl: providerTableMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl
|
|
1185
1246
|
? null
|
|
1186
1247
|
: rootTomlString(content, REALTIME_WS_BASE_URL_KEY),
|
|
1187
1248
|
// This is the catalog artifact selected for this injection, even when config.toml
|
|
@@ -1318,7 +1379,11 @@ export async function injectCodexConfig(
|
|
|
1318
1379
|
}
|
|
1319
1380
|
// Legacy mode still forward-tags history so re-tagged threads stay listable. Design B needs
|
|
1320
1381
|
// the opposite: a one-time migration of previously re-tagged threads BACK to openai (restore
|
|
1321
|
-
// machinery; cheap no-op when there is nothing to migrate).
|
|
1382
|
+
// machinery; cheap no-op when there is nothing to migrate). The client-compaction opt-in keeps
|
|
1383
|
+
// the root override alongside its table precisely so it does NOT have to touch history: an
|
|
1384
|
+
// existing `openai`-tagged thread still reaches this proxy through the built-in entry. So it
|
|
1385
|
+
// skips this unit, and future-only means what it says — no provider metadata is rewritten and
|
|
1386
|
+
// no `ocx1:` payload is touched.
|
|
1322
1387
|
// History runs in a Worker under H, not on this thread.
|
|
1323
1388
|
//
|
|
1324
1389
|
// The three surfaces it touches — the SQLite rows, the backup manifest, and the
|
|
@@ -1331,8 +1396,8 @@ export async function injectCodexConfig(
|
|
|
1331
1396
|
expectedDesiredEnabled: true,
|
|
1332
1397
|
operation: deriveCodexHistoryOperation({
|
|
1333
1398
|
direction: "apply",
|
|
1334
|
-
resumeHistory: config?.syncResumeHistory !== false,
|
|
1335
|
-
legacyMode,
|
|
1399
|
+
resumeHistory: config?.syncResumeHistory !== false && !keepRootOverrideAlongsideTable,
|
|
1400
|
+
legacyMode: providerTableMode,
|
|
1336
1401
|
}),
|
|
1337
1402
|
});
|
|
1338
1403
|
// A blocked or failed unit is reported, not silently counted as zero work:
|
|
@@ -1363,17 +1428,41 @@ export async function injectCodexConfig(
|
|
|
1363
1428
|
const ejected = (history as { ejectedRows?: number }).ejectedRows ?? 0;
|
|
1364
1429
|
const migratedRows = (history.rows ?? 0) + ejected;
|
|
1365
1430
|
const historyMessage =
|
|
1366
|
-
|
|
1431
|
+
keepRootOverrideAlongsideTable
|
|
1432
|
+
? (keptUserBaseUrl
|
|
1433
|
+
? ` Codex resume history: left unchanged; threads already tagged openai follow your own root openai_base_url, not the proxy.\n`
|
|
1434
|
+
: ` Codex resume history: left unchanged; existing threads keep reaching the proxy through the retained openai_base_url override.\n`)
|
|
1435
|
+
: config?.syncResumeHistory === false
|
|
1367
1436
|
? ` Codex resume history: left unchanged (syncResumeHistory=false).\n`
|
|
1368
1437
|
: history.failed
|
|
1369
|
-
? formatApplyHistoryFailure(historyOutcome,
|
|
1370
|
-
:
|
|
1438
|
+
? formatApplyHistoryFailure(historyOutcome, providerTableMode)
|
|
1439
|
+
: providerTableMode
|
|
1371
1440
|
? ` Codex resume history: ${history.rows} thread(s) made visible for opencodex; originals backed up for restore.\n`
|
|
1372
1441
|
: migratedRows > 0
|
|
1373
1442
|
? ` Codex resume history: restored original provider metadata for ${migratedRows} manifest-backed thread(s) (one-time).\n`
|
|
1374
1443
|
: ` Codex resume history: no backed-up metadata pending; untracked routed history left unchanged.\n`;
|
|
1375
|
-
// A user-owned root openai_base_url means we did NOT install routing — say so honestly
|
|
1444
|
+
// A user-owned root openai_base_url means we did NOT install root routing — say so honestly
|
|
1376
1445
|
// instead of claiming the proxy route is active (catalog/fast_mode were still written).
|
|
1446
|
+
//
|
|
1447
|
+
// The client-compaction form writes a provider table as well, so "nothing was injected" would
|
|
1448
|
+
// misdescribe the file it just produced: new threads do use the injected table. Report that
|
|
1449
|
+
// mixed result on its own terms, and never tell the operator to delete a setting of theirs.
|
|
1450
|
+
if (keptUserBaseUrl && keepRootOverrideAlongsideTable) {
|
|
1451
|
+
return {
|
|
1452
|
+
success: true,
|
|
1453
|
+
...(nativeSubagentDefaultsWarning ? { nativeSubagentDefaultsWarning } : {}),
|
|
1454
|
+
message:
|
|
1455
|
+
`Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n` +
|
|
1456
|
+
` Your root openai_base_url was left exactly as you set it, so opencodex did not add its own.\n` +
|
|
1457
|
+
catalogMessage +
|
|
1458
|
+
historyMessage +
|
|
1459
|
+
managedDefaultsMessage +
|
|
1460
|
+
` New threads use the injected opencodex provider and route through the proxy.\n` +
|
|
1461
|
+
` Threads already tagged openai resolve through Codex's built-in provider, which your root openai_base_url points at.\n` +
|
|
1462
|
+
` Remove that line and rerun 'ocx start' only if you want those threads on the proxy too.\n` +
|
|
1463
|
+
` Fallback: codex --profile opencodex (same behavior)`,
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1377
1466
|
if (keptUserBaseUrl) {
|
|
1378
1467
|
return {
|
|
1379
1468
|
success: true,
|
|
@@ -1391,7 +1480,9 @@ export async function injectCodexConfig(
|
|
|
1391
1480
|
}
|
|
1392
1481
|
const headline = routingTarget.desktopAuthless === true
|
|
1393
1482
|
? `Injected opencodex as default provider into Codex config (authless Desktop mode: requires_openai_auth = false).\n`
|
|
1394
|
-
:
|
|
1483
|
+
: routingTarget.clientCompaction === true
|
|
1484
|
+
? `Injected opencodex as default provider into Codex config (client-side compaction mode; ChatGPT auth remains required).\n`
|
|
1485
|
+
: providerTableMode
|
|
1395
1486
|
? `Injected opencodex as default provider into Codex config.\n`
|
|
1396
1487
|
: `Pointed Codex's built-in openai provider at the opencodex proxy (openai_base_url + realtime sideband override).\n`;
|
|
1397
1488
|
return {
|
|
@@ -1405,7 +1496,7 @@ export async function injectCodexConfig(
|
|
|
1405
1496
|
` All models now route through opencodex proxy (like OpenRouter).\n` +
|
|
1406
1497
|
` OpenAI models (gpt-5.5, etc.) are passed through to OpenAI.\n` +
|
|
1407
1498
|
` Custom models route to their configured providers.\n` +
|
|
1408
|
-
(
|
|
1499
|
+
(providerTableMode
|
|
1409
1500
|
? ` Fallback: codex --profile opencodex (same behavior)`
|
|
1410
1501
|
: ` Fallback reference: ${CODEX_PROFILE_PATH}`),
|
|
1411
1502
|
};
|
|
@@ -1733,6 +1824,12 @@ export function skippedRestoreEnvelope(success: boolean, message: string): Codex
|
|
|
1733
1824
|
function restoreCodexConfigInline(): CodexRestoreConfigResult {
|
|
1734
1825
|
try {
|
|
1735
1826
|
const journal = restoreJournalState();
|
|
1827
|
+
if (journal.unverified) {
|
|
1828
|
+
return {
|
|
1829
|
+
state: "failed", changed: false, action: "failed",
|
|
1830
|
+
message: "Codex journal recovery was not verified; current configuration files and the journal were preserved.",
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1736
1833
|
const restored = journal.configRestored
|
|
1737
1834
|
? { success: true, message: "Codex config restored from opencodex journal." }
|
|
1738
1835
|
: removeCodexConfig({ preserveProfile: journal.profileRestored || journal.profileChanged });
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
forgetEphemeralSecretPath,
|
|
17
17
|
hardenSecretPath,
|
|
18
18
|
} from "../../lib/windows-secret-acl";
|
|
19
|
+
import { resetCodexAppServerCatalogStateCache } from "../app-server-processes";
|
|
19
20
|
|
|
20
21
|
export interface PreparedCatalogFileWrite {
|
|
21
22
|
readonly path: string;
|
|
@@ -167,6 +168,7 @@ export function replaceActiveCodexCatalog(
|
|
|
167
168
|
): void {
|
|
168
169
|
assertCatalogWritePermit(permit, owningCodexHome);
|
|
169
170
|
atomicWriteFile(prepared.path, prepared.content, io);
|
|
171
|
+
resetCodexAppServerCatalogStateCache();
|
|
170
172
|
}
|
|
171
173
|
|
|
172
174
|
/** Atomically publish the catalog-path-keyed immutable backup without clobbering. */
|
|
@@ -200,4 +202,5 @@ export function replaceCodexModelsCache(
|
|
|
200
202
|
): void {
|
|
201
203
|
assertCatalogWritePermit(permit, owningCodexHome);
|
|
202
204
|
atomicWriteFile(prepared.path, prepared.content, io);
|
|
205
|
+
resetCodexAppServerCatalogStateCache();
|
|
203
206
|
}
|