@bitkyc08/opencodex 2.48.0 → 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.
Files changed (97) hide show
  1. package/README.md +8 -5
  2. package/SPONSORS.md +1 -1
  3. package/assets/sponsors/orcarouter.png +0 -0
  4. package/assets/sponsors/packycode.png +0 -0
  5. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  6. package/gui/dist/assets/index-BtyONQrZ.js +115 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/packycode.svg +19 -0
  9. package/gui/dist/provider-icons/qoder.svg +5 -0
  10. package/package.json +5 -3
  11. package/src/adapters/anthropic.ts +31 -16
  12. package/src/adapters/codebuddy/adapter.ts +85 -0
  13. package/src/adapters/codebuddy/profiles.ts +52 -0
  14. package/src/adapters/coding-agent/profile.ts +100 -0
  15. package/src/adapters/coding-agent/protocol.ts +463 -0
  16. package/src/adapters/coding-agent/turn.ts +353 -0
  17. package/src/adapters/google.ts +15 -11
  18. package/src/adapters/mimo-free.ts +3 -0
  19. package/src/adapters/openai-chat.ts +2 -2
  20. package/src/adapters/openai-responses.ts +18 -11
  21. package/src/adapters/qoder/adapter.ts +70 -0
  22. package/src/adapters/qoder/live-models.ts +89 -0
  23. package/src/adapters/qoder/profiles.ts +36 -0
  24. package/src/adapters/registry.ts +12 -0
  25. package/src/adapters/responses-tool-schema.ts +113 -8
  26. package/src/cli/dispatch.ts +2 -0
  27. package/src/cli/doctor.ts +40 -0
  28. package/src/cli/effort.ts +24 -8
  29. package/src/cli/help.ts +2 -0
  30. package/src/cli/index.ts +24 -1
  31. package/src/cli/provider-runtime.ts +2 -1
  32. package/src/cli/registry.ts +2 -2
  33. package/src/cli/system-command.ts +10 -3
  34. package/src/cli/usage-report.ts +9 -5
  35. package/src/codex/account-lifecycle.ts +35 -2
  36. package/src/codex/auth-api.ts +6 -3
  37. package/src/codex/auth-collision.ts +12 -2
  38. package/src/codex/auth-context.ts +75 -7
  39. package/src/codex/catalog/provider-fetch.ts +73 -9
  40. package/src/codex/inject.ts +127 -30
  41. package/src/codex/internal/catalog-writer.ts +3 -0
  42. package/src/codex/journal.ts +61 -12
  43. package/src/codex/model-cache.ts +11 -4
  44. package/src/codex/native-profile-startup.ts +72 -5
  45. package/src/codex/native-profile-store.ts +2 -2
  46. package/src/codex/ocx-compaction-history.ts +226 -0
  47. package/src/codex/project-config-warnings.ts +3 -1
  48. package/src/codex/quota.ts +18 -8
  49. package/src/codex/reserve-availability.ts +21 -5
  50. package/src/codex/runtime.ts +45 -1
  51. package/src/codex/sync.ts +5 -0
  52. package/src/config.ts +1 -0
  53. package/src/generated/compatibility-version.json +126 -74
  54. package/src/images/loop.ts +1 -0
  55. package/src/images/xai-video-client.ts +2 -0
  56. package/src/integrations/registry.ts +1 -0
  57. package/src/lib/process-control.ts +52 -8
  58. package/src/lib/upstream-retry.ts +1 -0
  59. package/src/oauth/chatgpt.ts +83 -0
  60. package/src/oauth/xai.ts +151 -8
  61. package/src/providers/api-key-selection-capture.ts +10 -0
  62. package/src/providers/api-key-selection.ts +2 -7
  63. package/src/providers/caller-authorization.ts +36 -0
  64. package/src/providers/codebuddy-models.ts +184 -0
  65. package/src/providers/derive.ts +5 -0
  66. package/src/providers/free-directory.ts +26 -2
  67. package/src/providers/openai-sidecar.ts +35 -11
  68. package/src/providers/qoder-models.ts +25 -0
  69. package/src/providers/registry.ts +139 -0
  70. package/src/responses/compaction.ts +4 -0
  71. package/src/responses/task-input.ts +21 -1
  72. package/src/router.ts +1 -1
  73. package/src/server/chat-completions.ts +26 -9
  74. package/src/server/claude-messages.ts +12 -2
  75. package/src/server/images.ts +1 -0
  76. package/src/server/management/account-selection-stream.ts +13 -4
  77. package/src/server/management/config-routes.ts +24 -5
  78. package/src/server/management/logs-usage-routes.ts +1 -0
  79. package/src/server/management/native-integration-routes.ts +2 -1
  80. package/src/server/management/provider-routes.ts +24 -0
  81. package/src/server/management-api.ts +14 -0
  82. package/src/server/port-reclaim.ts +11 -26
  83. package/src/server/request-log.ts +6 -0
  84. package/src/server/responses/collaboration.ts +4 -9
  85. package/src/server/responses/core.ts +141 -19
  86. package/src/server/responses/fetch-helpers.ts +18 -7
  87. package/src/server/responses/policy-fallback.ts +12 -0
  88. package/src/service.ts +36 -2
  89. package/src/storage/cleanup.ts +77 -45
  90. package/src/types/config.ts +7 -1
  91. package/src/usage/log.ts +24 -0
  92. package/src/vision/anthropic-describe.ts +1 -0
  93. package/src/web-search/anthropic-executor.ts +1 -0
  94. package/src/web-search/loop.ts +1 -0
  95. package/src/web-search/progress-stream.ts +4 -0
  96. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  97. 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 preserveRequestOwnedMainPin = requestScopedMainCredential
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
- const metadata = metadataProvider ? comboMemberVendorMetadata(metadataProvider, target.model) : undefined;
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
- const capped = applyProviderContextCap(existing.contextWindow, contextCap);
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(existing);
1056
+ return withFallbackMetadata(hinted);
1044
1057
  }
1045
- const maxInput = typeof existing.maxInputTokens === "number" && existing.maxInputTokens > 0
1046
- ? Math.min(existing.maxInputTokens, capped)
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
- ...existing,
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
@@ -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 for non-loopback admission and for the authless Desktop opt-in. */
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 || target.desktopAuthless === true;
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<OcxConfig, "hostname" | "unauthenticatedLoopbackListener" | "codexDesktopAuthless">,
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); the authless Desktop opt-in shares that shape.
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 (legacy) or the authless Desktop opt-in (#1107).
1018
- const legacyMode = usesProviderTable(routingTarget);
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 (legacyMode) {
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: !hasInjectedCodexRouting(rawContent) && !looksInjectedByValue,
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 only in loopback Design B when no user-owned value won.
1179
- injectedOpenaiBaseUrl: legacyMode || keptUserBaseUrl
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: legacyMode || keptUserBaseUrl || keptUserRealtimeWsBaseUrl
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
- config?.syncResumeHistory === false
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, legacyMode)
1370
- : legacyMode
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
- : legacyMode
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
- (legacyMode
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
  }