@bitkyc08/opencodex 2.73.0 → 2.74.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 (58) hide show
  1. package/gui/dist/assets/{App-mHGa7dE1.js → App-B00Yq11u.js} +15 -15
  2. package/gui/dist/assets/App-KLz1TAXi.css +1 -0
  3. package/gui/dist/assets/{Tray-BxY_RYZg.js → Tray-D3Ij1Cvf.js} +1 -1
  4. package/gui/dist/assets/{index-ur_BPgK1.js → index-BYQQfBGK.js} +12 -12
  5. package/gui/dist/assets/index-DBT3Q31Y.css +1 -0
  6. package/gui/dist/assets/{quota-summary-D1Vq7N9H.js → quota-summary-yPtcEhGI.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/kiro/stream.ts +49 -28
  10. package/src/adapters/openai-chat.ts +3 -5
  11. package/src/adapters/openai-responses/passthrough.ts +2 -5
  12. package/src/codex/auth-api/account-list.ts +14 -2
  13. package/src/codex/auth-api/main-account-probe.ts +2 -0
  14. package/src/codex/auth-api/pool-quota-probe.ts +4 -0
  15. package/src/codex/autostart-health.ts +15 -3
  16. package/src/codex/catalog/discovered-natives.ts +166 -0
  17. package/src/codex/catalog/metadata.ts +25 -6
  18. package/src/codex/catalog/model-hints.ts +8 -0
  19. package/src/codex/catalog/model-visibility.ts +4 -0
  20. package/src/codex/catalog/native-models.ts +46 -19
  21. package/src/codex/catalog-auto-refresh-sources.ts +71 -0
  22. package/src/codex/catalog-auto-refresh.ts +42 -6
  23. package/src/codex/catalog-refresh-status.ts +4 -0
  24. package/src/codex/credits.ts +86 -0
  25. package/src/codex/main-account-hard-lock.ts +2 -2
  26. package/src/codex/model-entitlements.ts +122 -1
  27. package/src/codex/quota-types.ts +1 -0
  28. package/src/codex/quota.ts +4 -3
  29. package/src/codex/shim-probe.ts +5 -1
  30. package/src/codex/shim-templates.ts +10 -4
  31. package/src/config/derived-registries.ts +4 -2
  32. package/src/config/diagnostics.ts +9 -0
  33. package/src/config/feature-flags.ts +6 -4
  34. package/src/config/schema/config-schema.ts +1 -0
  35. package/src/config/schema/leaf-validators.ts +1 -0
  36. package/src/generated/compatibility-version.json +67 -43
  37. package/src/generated/model-metadata.ts +1 -1
  38. package/src/lib/test-home-guard.ts +47 -3
  39. package/src/providers/fastwire.ts +22 -0
  40. package/src/providers/model-rename-startup.ts +7 -5
  41. package/src/providers/registry/entries-core.ts +13 -6
  42. package/src/providers/registry/entries-extended.ts +14 -8
  43. package/src/providers/registry/model-seeds.ts +14 -1
  44. package/src/providers/stale-model-roster-migration.ts +101 -0
  45. package/src/providers/xai-fast-model.ts +85 -0
  46. package/src/server/auth-cors.ts +1 -0
  47. package/src/server/background-lifecycle.ts +2 -2
  48. package/src/server/management/config-routes.ts +13 -1
  49. package/src/server/management/oauth-account-routes.ts +8 -2
  50. package/src/server/responses/core-normalize.ts +3 -0
  51. package/src/server/startup-health-cache.ts +7 -2
  52. package/src/service/desktop-startup.ts +93 -0
  53. package/src/types/config.ts +12 -11
  54. package/src/types/provider.ts +3 -1
  55. package/src/types/request.ts +2 -0
  56. package/src/usage/log.ts +3 -1
  57. package/gui/dist/assets/App-DCBismRi.css +0 -1
  58. package/gui/dist/assets/index-pWYJt_vL.css +0 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Opt-in periodic catalog refresh so newly released models appear without a
2
+ * Default-on periodic catalog refresh so newly released models appear without a
3
3
  * manual `ocx sync` (issue #3630).
4
4
  *
5
5
  * This is load-bearing, not a convenience. The served model set is otherwise
@@ -24,6 +24,7 @@
24
24
  */
25
25
  const DEFAULT_INTERVAL_MS = 60 * 60_000;
26
26
  const MIN_INTERVAL_MS = 15 * 60_000;
27
+ const INITIAL_DELAY_MS = 3 * 60_000;
27
28
  /**
28
29
  * Commit-lock wait only. Gather already has per-provider timeouts, and automatic
29
30
  * callers fail fast and defer (ConvergeRequest.mode) rather than holding the
@@ -37,6 +38,7 @@ const MAX_JOURNAL_BYTES = 1024 * 1024;
37
38
  const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
38
39
 
39
40
  let timer: ReturnType<typeof setInterval> | null = null;
41
+ let initialTimer: ReturnType<typeof setTimeout> | null = null;
40
42
  let detachShutdownHook: (() => void) | null = null;
41
43
  /** The bounded cadence the live timer was created with, so a tick can notice config drift. */
42
44
  let liveIntervalMs: number | null = null;
@@ -170,8 +172,10 @@ function boundedInterval(value: number): number {
170
172
  /** Re-arm the timer when the operator changed the cadence since it was created. */
171
173
  function restartIfCadenceChanged(configured: number): void {
172
174
  if (timer === null || boundedInterval(configured) === liveIntervalMs) return;
173
- stopCatalogAutoRefresh();
174
- startCatalogAutoRefresh(configured);
175
+ clearInterval(timer);
176
+ liveIntervalMs = boundedInterval(configured);
177
+ timer = setInterval(() => void tick(), liveIntervalMs);
178
+ timer.unref?.();
175
179
  }
176
180
 
177
181
  async function tick(): Promise<void> {
@@ -199,6 +203,13 @@ async function tick(): Promise<void> {
199
203
  // and the unref'd timer is left running so flipping the minutes back on is
200
204
  // picked up without a process restart.
201
205
  if (configured === 0) return;
206
+ // The default-on case belongs to installs whose local Codex client this proxy manages. With
207
+ // the integration off (or on a hub or sibling), an absent section keeps the old opt-in
208
+ // meaning: no background converge may write the native Codex home unasked. An explicit
209
+ // `enabled: true` still refreshes, as it did before the default flipped.
210
+ const { shouldSyncCodexOnStart } = await import("./desired-state");
211
+ const codexManaged = shouldSyncCodexOnStart(config);
212
+ if (!codexManaged && config.catalogAutoRefresh?.enabled !== true) return;
202
213
  // A stop or restart landed while the config resolved: this tick no longer owns the timer,
203
214
  // so it must neither count as a refresh nor adopt a cadence for a generation that is gone.
204
215
  if (entryGeneration !== generation) return;
@@ -218,6 +229,12 @@ async function tick(): Promise<void> {
218
229
  } else if (heal === "not-healed") {
219
230
  console.info("[catalog-auto-refresh] injected Codex config keys were rewritten externally; not re-injected this tick");
220
231
  }
232
+ const { refreshCatalogAutoRefreshSources, catalogAutoRefreshReloadRequired } =
233
+ await import("./catalog-auto-refresh-sources");
234
+ if (entryGeneration !== generation) return;
235
+ // Codex sources read Codex credentials and probe its binary; only a managed client needs them.
236
+ if (codexManaged) await refreshCatalogAutoRefreshSources(config, () => entryGeneration === generation);
237
+ if (entryGeneration !== generation) return;
221
238
  const [{ createManagementConvergeCodex }, { createCatalogConvergeRequest }] = await Promise.all([
222
239
  import("./management-convergence"),
223
240
  import("./catalog-admission"),
@@ -231,11 +248,16 @@ async function tick(): Promise<void> {
231
248
  // createManagementConvergeCodex always projects catalog-only. Any other kind is a
232
249
  // funnel contract break, not something this scheduler should re-classify.
233
250
  if (outcome.kind !== "catalog-only") return;
234
- const { recordCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status");
235
- recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed);
251
+ const { recordCatalogAutoRefreshOutcome, lastCatalogAutoRefreshOutcome } = await import("./catalog-refresh-status");
252
+ const reloadRequired = (outcome.changed || lastCatalogAutoRefreshOutcome()?.reloadRequired === true)
253
+ ? await catalogAutoRefreshReloadRequired(outcome.changed) : false;
254
+ if (entryGeneration !== generation) return;
255
+ recordCatalogAutoRefreshOutcome(outcome.catalogRefresh, outcome.changed, reloadRequired);
236
256
  if (outcome.changed) {
237
257
  // Privacy scan: no provider names, model ids, paths, or account identifiers.
238
- console.info("[catalog-auto-refresh] served model set changed");
258
+ console.info(reloadRequired
259
+ ? "[catalog-auto-refresh] served model set changed; running Codex sessions keep the old list until restarted (ocx sync --restart-codex)"
260
+ : "[catalog-auto-refresh] served model set changed");
239
261
  }
240
262
  } catch {
241
263
  // A failed refresh is not an error worth surfacing: the next tick tries again.
@@ -253,8 +275,16 @@ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void
253
275
  timer = setInterval(() => void tick(), bounded);
254
276
  // Never keep the process alive for a catalog refresh.
255
277
  timer.unref?.();
278
+ const startedGeneration = generation;
279
+ initialTimer = setTimeout(() => {
280
+ if (startedGeneration !== generation) return;
281
+ initialTimer = null;
282
+ return tick();
283
+ }, INITIAL_DELAY_MS);
284
+ initialTimer.unref?.();
256
285
  void import("../lib/optional-shutdown-hooks")
257
286
  .then(hooks => {
287
+ if (startedGeneration !== generation) return;
258
288
  detachShutdownHook = hooks.registerOptionalShutdownHook(
259
289
  "catalog-auto-refresh",
260
290
  stopCatalogAutoRefresh,
@@ -266,6 +296,10 @@ export function startCatalogAutoRefresh(intervalMs = DEFAULT_INTERVAL_MS): void
266
296
  }
267
297
 
268
298
  export function stopCatalogAutoRefresh(): void {
299
+ if (initialTimer) {
300
+ clearTimeout(initialTimer);
301
+ initialTimer = null;
302
+ }
269
303
  if (timer) {
270
304
  clearInterval(timer);
271
305
  timer = null;
@@ -290,7 +324,9 @@ export function isCatalogAutoRefreshRunning(): boolean {
290
324
  * microtasks before this settles.
291
325
  */
292
326
  export async function syncCatalogAutoRefreshCadence(): Promise<void> {
327
+ const entryGeneration = generation;
293
328
  const { loadConfig, resolveCatalogAutoRefreshIntervalMs } = await import("../config");
329
+ if (entryGeneration !== generation) return;
294
330
  const configured = resolveCatalogAutoRefreshIntervalMs(loadConfig());
295
331
  // 0 is dormant: tick() already returns before converging, and the timer stays unref'd.
296
332
  if (configured === 0) return;
@@ -108,6 +108,7 @@ export interface CatalogAutoRefreshOutcome {
108
108
  readonly at: number;
109
109
  readonly disposition: CatalogDisposition;
110
110
  readonly changed: boolean;
111
+ readonly reloadRequired: boolean;
111
112
  /**
112
113
  * A refresh that has failed repeatedly is the signal an operator needs, and the
113
114
  * boolean disposition alone cannot express it: skipped and failed look the same
@@ -158,6 +159,7 @@ function freezeCatalogAutoRefreshOutcome(
158
159
  at: outcome.at,
159
160
  disposition: freezeCatalogDisposition(outcome.disposition),
160
161
  changed: outcome.changed,
162
+ reloadRequired: outcome.reloadRequired,
161
163
  consecutiveFailures: outcome.consecutiveFailures,
162
164
  });
163
165
  }
@@ -171,6 +173,7 @@ function freezeCatalogAutoRefreshOutcome(
171
173
  export function recordCatalogAutoRefreshOutcome(
172
174
  disposition: CatalogDisposition,
173
175
  changed: boolean,
176
+ reloadRequired = false,
174
177
  ): CatalogAutoRefreshOutcome | null {
175
178
  const normalized = normalizeCatalogDisposition(disposition);
176
179
  if (normalized === null) return null;
@@ -181,6 +184,7 @@ export function recordCatalogAutoRefreshOutcome(
181
184
  at: Date.now(),
182
185
  disposition: normalized,
183
186
  changed: changed === true,
187
+ reloadRequired: reloadRequired === true,
184
188
  consecutiveFailures,
185
189
  });
186
190
  lastAutoRefreshOutcome = outcome;
@@ -0,0 +1,86 @@
1
+ import type { OcxConfig } from "../types";
2
+
3
+ /** Display-only WHAM observation, never persisted or used for routing. */
4
+ export interface CodexCredits {
5
+ hasCredits?: boolean;
6
+ unlimited?: boolean;
7
+ overageLimitReached?: boolean;
8
+ balance?: string;
9
+ approxLocalMessages?: [number, number];
10
+ approxCloudMessages?: [number, number];
11
+ }
12
+
13
+ /**
14
+ * An entry without `credits` means "this identity was observed and reported nothing to show".
15
+ * Keeping that apart from "never observed" lets the account listing bypass the persisted quota
16
+ * cache exactly once per identity after a restart, instead of on every dashboard poll.
17
+ */
18
+ const observations = new Map<string, { identity: string; credits?: CodexCredits }>();
19
+
20
+ function messageRange(raw: unknown): [number, number] | undefined {
21
+ return Array.isArray(raw) && raw.length === 2
22
+ && raw.every(value => typeof value === "number" && Number.isFinite(value) && value >= 0)
23
+ ? [raw[0], raw[1]] : undefined;
24
+ }
25
+
26
+ /** Undefined keeps the prior observation; null or unusable input clears it. */
27
+ export function parseCodexCredits(raw: unknown): CodexCredits | null | undefined {
28
+ if (raw === undefined) return undefined;
29
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
30
+ const value = raw as Record<string, unknown>;
31
+ const credits: CodexCredits = {};
32
+ if (typeof value.has_credits === "boolean") credits.hasCredits = value.has_credits;
33
+ if (typeof value.unlimited === "boolean") credits.unlimited = value.unlimited;
34
+ if (typeof value.overage_limit_reached === "boolean") credits.overageLimitReached = value.overage_limit_reached;
35
+ if (typeof value.balance === "string" && /^\d+(\.\d+)?$/.test(value.balance)) credits.balance = value.balance;
36
+ else if (typeof value.balance === "number" && Number.isFinite(value.balance) && value.balance >= 0) {
37
+ // String(1e-7) is "1e-7"; the DTO promises a plain decimal string, which the GUI validates.
38
+ credits.balance = value.balance.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 });
39
+ }
40
+ const local = messageRange(value.approx_local_messages);
41
+ const cloud = messageRange(value.approx_cloud_messages);
42
+ if (local) credits.approxLocalMessages = local;
43
+ if (cloud) credits.approxCloudMessages = cloud;
44
+ return Object.keys(credits).length > 0 ? credits : null;
45
+ }
46
+
47
+ export function rememberCodexCredits(accountId: string, identity: string, parsed: CodexCredits | null | undefined): void {
48
+ const previous = observations.get(accountId);
49
+ if (parsed === undefined) {
50
+ // Omission keeps a same-identity observation; otherwise it still records that this identity
51
+ // answered, so the listing stops forcing fresh reads for it.
52
+ if (previous?.identity !== identity) observations.set(accountId, { identity });
53
+ return;
54
+ }
55
+ observations.set(accountId, parsed === null ? { identity } : { identity, credits: structuredClone(parsed) });
56
+ }
57
+
58
+ export function codexCreditsFor(accountId: string, identity: string | null): CodexCredits | undefined {
59
+ const observation = observations.get(accountId);
60
+ if (!observation) return undefined;
61
+ if (identity !== observation.identity) {
62
+ observations.delete(accountId);
63
+ return undefined;
64
+ }
65
+ return observation.credits ? structuredClone(observation.credits) : undefined;
66
+ }
67
+
68
+ /** Whether this identity has answered at least one usage read since the process started. */
69
+ export function hasCodexCreditsObservation(accountId: string, identity: string | null): boolean {
70
+ const observation = observations.get(accountId);
71
+ return observation !== undefined && identity !== null && observation.identity === identity;
72
+ }
73
+
74
+ export function codexCreditsDtoField(config: Pick<OcxConfig, "showCodexCredits">, accountId: string, identity: string | null): { credits?: CodexCredits } {
75
+ const credits = codexCreditsFor(accountId, identity);
76
+ return config.showCodexCredits === true && credits ? { credits } : {};
77
+ }
78
+
79
+ export function pruneCodexCredits(liveAccountIds: Iterable<string>): void {
80
+ const live = new Set(liveAccountIds);
81
+ for (const id of observations.keys()) if (!live.has(id)) observations.delete(id);
82
+ }
83
+
84
+ export function resetCodexCreditsForTests(): void {
85
+ observations.clear();
86
+ }
@@ -66,9 +66,9 @@ export function getMainAccountHardLockStatus(
66
66
  const windows = governingWindows(quota);
67
67
  const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT);
68
68
  if (blocking.length > 0) {
69
- // The lock holds until every blocking window reads lower, so the earliest possible unlock is
69
+ // The lock holds until every blocking window reads lower or is authoritatively absent, so the earliest possible unlock is
70
70
  // the latest blocking reset. One blocking window without a future reset makes it unknowable.
71
- // A predicted reset is not evidence of recovery either way: only a fresh lower reading releases.
71
+ // A predicted reset is not evidence of recovery; fresh lower usage or validated WHAM absence releases.
72
72
  const resets = blocking.map(w => resetTimestamp(w.resetAt));
73
73
  const resetAt = resets.every(r => r !== undefined && r > now) ? Math.max(...(resets as number[])) : undefined;
74
74
  return { enabled: true, state: "blocked", ...(resetAt !== undefined ? { resetAt } : {}) };
@@ -20,6 +20,7 @@ import {
20
20
  } from "./catalog/native-models";
21
21
  import { loadPersistedCodexRuntime } from "./runtime";
22
22
  import { codexRuntimeStateEpoch } from "./runtime";
23
+ import { recordDiscoveredNativeModels, validateDiscoveredNativeRows } from "./catalog/discovered-natives";
23
24
  import { pinnedNativeModelRows } from "./catalog/pinned-models";
24
25
  import { codexCredentialMutationEpoch } from "./credential-mutation-epoch";
25
26
  import {
@@ -650,6 +651,7 @@ function boundedAvailabilityMessage(message: string): string {
650
651
 
651
652
  /** Keep valid roster slugs while dropping malformed program and availability metadata. */
652
653
  function parseAccountModels(text: string): {
654
+ discoveredRows: Record<string, unknown>[];
653
655
  models: ReadonlySet<string>;
654
656
  accessProgramsByModel: ReadonlyMap<string, CodexAvailableAccessPrograms>;
655
657
  availabilityNuxByModel: ReadonlyMap<string, { message: string }>;
@@ -681,7 +683,8 @@ function parseAccountModels(text: string): {
681
683
  }
682
684
  return [row.slug];
683
685
  });
684
- return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel };
686
+ return { models: new Set(models), accessProgramsByModel, availabilityNuxByModel,
687
+ discoveredRows: validateDiscoveredNativeRows(payload.models) };
685
688
  } catch {
686
689
  return null;
687
690
  }
@@ -757,6 +760,7 @@ async function fetchAccountModels(
757
760
  if (!usable) {
758
761
  return unconfirmedAccountModels(credential, clientVersion, now, { kind: "parsed-empty" });
759
762
  }
763
+ recordDiscoveredNativeModels(parsed.discoveredRows, clientVersion, now);
760
764
  const hasUnknownGatedAbsence = [...ACCOUNT_GATED_NATIVE_MODEL_MINIMUM_CLIENT_VERSIONS]
761
765
  // Reachable only through tier 1, an inbound client_version below the floor. Every other
762
766
  // resolution is now structurally >= every recorded minimum, because the floor is the max
@@ -789,6 +793,123 @@ async function fetchAccountModels(
789
793
  }
790
794
  }
791
795
 
796
+ /**
797
+ * Client version the discovery-only roster request claims.
798
+ *
799
+ * Upstream filters `/models` by client version AND by a rollout gate the row's own
800
+ * `minimal_client_version` does not describe: on 2026-09-30 `gpt-6.1-sol` carried
801
+ * `minimal_client_version: "0.153.0"` yet was served only to `client_version >= 0.159.0`, while the
802
+ * installed Codex was 0.158.0-alpha. Asking under the installed version (what the entitlement path
803
+ * must do, #2886) therefore could not see the model at all. Discovery asks as a client newer than
804
+ * any release so it sees the whole roster; this proxy already serves pinned rows to older
805
+ * clients, so the version only widens what we learn, never what an account may call.
806
+ */
807
+ export const CODEX_ROSTER_DISCOVERY_CLIENT_VERSION = "99.0.0";
808
+
809
+ export type CodexNativeRosterDiscoveryOutcome = "recorded" | "not-modified" | "unavailable";
810
+
811
+ export interface CodexNativeRosterDiscoveryOptions extends Pick<
812
+ CodexModelEntitlementResolveOptions,
813
+ "credentials" | "fetcher" | "signal" | "now" | "nativeMainRefreshDependencies"
814
+ > {
815
+ /** Checked right before publication; false means the caller's scheduler generation is gone. */
816
+ readonly isCurrent?: () => boolean;
817
+ }
818
+
819
+ /**
820
+ * Last ETag per credential identity, so an unchanged roster costs a 304 when upstream honours it.
821
+ * A 304 carries no rows and so cannot renew a discovery's last-seen time; a model visible only
822
+ * under the discovery version would then expire after its retention while still being served.
823
+ * The ETag is therefore used only within a day of the full fetch that earned it.
824
+ */
825
+ const discoveryEtags = new Map<string, { etag: string; fetchedAt: number }>();
826
+ const DISCOVERY_ETAG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
827
+
828
+ /**
829
+ * Background discovery of native rows this build does not pin, independent of entitlement.
830
+ *
831
+ * It never writes the entitlement cache: a roster fetched under the discovery version answers a
832
+ * different question than "may this client call this model", and letting it satisfy entitlement
833
+ * reads would reintroduce the cross-version leak #2548/#2886 closed. The first account that
834
+ * answers is enough to learn a row; whether a given account may call it stays with the
835
+ * entitlement path and, for ungated rows, with the upstream status the request receives.
836
+ */
837
+ export async function discoverCodexNativeRoster(
838
+ config: Pick<OcxConfig, "codexAccounts">,
839
+ options: CodexNativeRosterDiscoveryOptions = {},
840
+ ): Promise<CodexNativeRosterDiscoveryOutcome> {
841
+ const fetcher = options.fetcher ?? fetch;
842
+ const run = async (excluded: ReadonlySet<string>, releaseMainLease?: () => void) => {
843
+ const credentials: CodexModelEntitlementCredentialSnapshot[] = [...(options.credentials ?? [])];
844
+ for (const accountId of options.credentials ? [] : normalizedCandidateAccountIds(config)) {
845
+ if (excluded.has(accountId)) continue;
846
+ const credential = await accountCredentialSnapshot(accountId, {
847
+ nativeMainRefreshDependencies: options.nativeMainRefreshDependencies,
848
+ signal: options.signal,
849
+ }).catch(() => null);
850
+ if (credential) credentials.push(credential);
851
+ }
852
+ releaseMainLease?.();
853
+ for (const credential of credentials) {
854
+ const outcome = await fetchDiscoveryRoster(credential, fetcher, options).catch(() => "unavailable" as const);
855
+ if (outcome !== "unavailable") return outcome;
856
+ }
857
+ return "unavailable" as const;
858
+ };
859
+ try {
860
+ return await withNativeMainCredentialAdmission(run);
861
+ } catch {
862
+ return "unavailable";
863
+ }
864
+ }
865
+
866
+ async function fetchDiscoveryRoster(
867
+ credential: CodexModelEntitlementCredentialSnapshot,
868
+ fetcher: typeof fetch,
869
+ options: Pick<CodexNativeRosterDiscoveryOptions, "signal" | "now" | "isCurrent">,
870
+ ): Promise<CodexNativeRosterDiscoveryOutcome> {
871
+ const controller = new AbortController();
872
+ const abort = () => controller.abort(options.signal?.reason);
873
+ options.signal?.addEventListener("abort", abort, { once: true });
874
+ const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS);
875
+ try {
876
+ const headers = new Headers({ Authorization: `Bearer ${credential.accessToken}`, Accept: "application/json" });
877
+ if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId);
878
+ const now = options.now ?? Date.now();
879
+ const cached = discoveryEtags.get(credential.credentialIdentity);
880
+ if (cached && now - cached.fetchedAt < DISCOVERY_ETAG_MAX_AGE_MS) headers.set("If-None-Match", cached.etag);
881
+ const response = await fetcher(codexModelsUrl(CODEX_ROSTER_DISCOVERY_CLIENT_VERSION), {
882
+ headers,
883
+ redirect: "error",
884
+ signal: controller.signal,
885
+ });
886
+ if (response.status === 304) return "not-modified";
887
+ if (!response.ok) return "unavailable";
888
+ const body = await readBoundedResponseBody(response, {
889
+ signal: controller.signal,
890
+ maxBytes: MODEL_ROSTER_MAX_BYTES,
891
+ fatalUtf8: true,
892
+ });
893
+ if (!body.displaySafe || body.truncated) return "unavailable";
894
+ const parsed = parseAccountModels(body.text);
895
+ if (parsed === null || parsed.models.size === 0) return "unavailable";
896
+ if (controller.signal.aborted || options.isCurrent?.() === false) return "unavailable";
897
+ recordDiscoveredNativeModels(parsed.discoveredRows, CODEX_ROSTER_DISCOVERY_CLIENT_VERSION, now);
898
+ const nextEtag = response.headers.get("etag");
899
+ if (nextEtag && nextEtag.length <= 256) discoveryEtags.set(credential.credentialIdentity, { etag: nextEtag, fetchedAt: now });
900
+ else discoveryEtags.delete(credential.credentialIdentity);
901
+ if (discoveryEtags.size > 64) discoveryEtags.delete(discoveryEtags.keys().next().value!);
902
+ return "recorded";
903
+ } finally {
904
+ clearTimeout(timer);
905
+ options.signal?.removeEventListener("abort", abort);
906
+ }
907
+ }
908
+
909
+ export function resetCodexNativeRosterDiscoveryForTests(): void {
910
+ discoveryEtags.clear();
911
+ }
912
+
792
913
  function directCallerCredential(headers: Headers): CodexModelEntitlementCredentialSnapshot | null {
793
914
  const match = /^Bearer\s+(\S+)$/i.exec(headers.get("authorization")?.trim() ?? "");
794
915
  if (!match) return null;
@@ -118,6 +118,7 @@ export type WhamUsageResponse = {
118
118
  secondary_window?: WhamUsageWindow | null;
119
119
  tertiary_window?: WhamUsageWindow | null;
120
120
  };
121
+ credits?: unknown;
121
122
  rate_limit_reset_credits?: { available_count: number } | null;
122
123
  additional_rate_limits?: WhamAdditionalRateLimit[] | null;
123
124
  };
@@ -827,8 +827,8 @@ function filterMainPolicyMonthlyQuota(
827
827
 
828
828
  /**
829
829
  * Parse ordinary main-policy usage, rejecting messages with invalid numeric window percentages.
830
- * Mark a valid primary of at least 24h as replacement evidence only when both other windows
831
- * are explicitly null or at least 24h. A null result supplies no usable policy observation.
830
+ * A measured long primary, or explicitly absent primary with measured weekly secondary,
831
+ * proves replacement only with complete long/null topology. Null supplies no usable evidence.
832
832
  */
833
833
  export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQuotaObservation | null {
834
834
  const windows = [data.rate_limit?.primary_window, data.rate_limit?.secondary_window, data.rate_limit?.tertiary_window];
@@ -840,7 +840,8 @@ export function parseMainPolicyUsageQuota(data: WhamUsageResponse): MainPolicyQu
840
840
  // carries a valid usage reading: a long window without used_percent leaves that
841
841
  // window's usage unknown, and unknown usage must never release a block.
842
842
  // Headers never supply this proof, and reset time alone still cannot release a block.
843
- if (quota && normalizeUsagePercent(primary?.used_percent) !== undefined && isExplicitLongWindow(primary)
843
+ if (quota && (isMeasuredLongWindow(primary)
844
+ || (primary === null && isMeasuredLongWindow(secondary) && quota.weeklyPercent !== undefined))
844
845
  && (secondary === null || isMeasuredLongWindow(secondary))
845
846
  && (tertiary === null || isMeasuredLongWindow(tertiary))) {
846
847
  return { ...quota, shortWindowAbsent: true };
@@ -146,9 +146,12 @@ function finishAfterStderr(status) {
146
146
  }
147
147
 
148
148
  try {
149
+ // BUN_BE_BUN selects the supervisor's interpreter mode, not the saved launcher.
150
+ const launcherEnv = { ...process.env };
151
+ delete launcherEnv.BUN_BE_BUN;
149
152
  launcher = spawn(launcherShellPath, [wrapperPath, "--version"], {
150
153
  detached: true,
151
- env: process.env,
154
+ env: launcherEnv,
152
155
  stdio: ["ignore", "ignore", "pipe", "pipe"],
153
156
  });
154
157
  if (!launcher.pid) throw new Error("Codex shim probe launcher has no pid");
@@ -252,6 +255,7 @@ function probeUnixShimInstall(wrapperPath: string): UnixShimProbeResult {
252
255
  const stderrPath = join(probeDir, "stderr");
253
256
  const env: NodeJS.ProcessEnv = {
254
257
  ...process.env,
258
+ BUN_BE_BUN: "1",
255
259
  OCX_SHIM_BYPASS: "1",
256
260
  OCX_SHIM_PROBE: "1",
257
261
  OCX_SHIM_PROBE_REENTRY_PATH: reentryPath,
@@ -1,6 +1,7 @@
1
1
  import { BUN_RUNTIME_PATH_ENV, BUN_RUNTIME_SOURCE_ENV } from "../lib/bun-runtime";
2
2
  import type { BunRuntimeSource } from "../lib/bun-runtime";
3
3
  import { serviceApiTokenFilePath } from "../lib/service-secrets";
4
+ import { selfLaunchArgv } from "../lib/self-launch-argv";
4
5
  import { windowsEnvIndirectBatchValue } from "../lib/win-paths";
5
6
 
6
7
  const SHIM_MARKER = "opencodex codex autostart shim";
@@ -83,6 +84,8 @@ function shQuote(value: string): string {
83
84
  export function buildUnixCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource, tokenFile = serviceApiTokenFilePath()): string {
84
85
  const internalCommands = CODEX_INTERNAL_COMMANDS.join("|");
85
86
  const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.join("|");
87
+ const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" })
88
+ .map(arg => `${shQuote(arg)} `).join("");
86
89
  return `#!/usr/bin/env sh
87
90
  # ${SHIM_MARKER}
88
91
  # ${UNIX_SHIM_REVISION_MARKER}
@@ -154,7 +157,7 @@ case "$ocx_subcommand" in
154
157
  ;;
155
158
  *)
156
159
  if [ -z "$OCX_SHIM_BYPASS" ]; then
157
- if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${shQuote(cliPath)} ensure >/dev/null 2>&1; then
160
+ if ! ${BUN_RUNTIME_SOURCE_ENV}=${shQuote(bunRuntimeSource)} ${BUN_RUNTIME_PATH_ENV}=${shQuote(bunPath)} ${shQuote(bunPath)} ${cliArgs}ensure >/dev/null 2>&1; then
158
161
  printf '%s\\n' ${shQuote(CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC)} >&2
159
162
  fi
160
163
  fi
@@ -184,12 +187,13 @@ function windowsBatchSet(name: string, value: string): string {
184
187
  export function buildWindowsCodexShim(realCodexPath: string, bunPath: string, cliPath: string, bunRuntimeSource: BunRuntimeSource): string {
185
188
  const internalCommandChecks = CODEX_INTERNAL_COMMANDS.map(command => `if /I "%~1"=="${command}" goto run_codex`).join("\r\n");
186
189
  const valueOptionChecks = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => `if /I "%~1"=="${option}" goto skip_option_value`).join("\r\n");
190
+ const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" });
187
191
  return `@echo off\r
188
192
  rem ${SHIM_MARKER}\r
189
193
  setlocal\r
190
194
  ${windowsBatchSet("OCX_REAL_CODEX", realCodexPath)}\r
191
195
  ${windowsBatchSet("OCX_BUN", bunPath)}\r
192
- ${windowsBatchSet("OCX_CLI", cliPath)}\r
196
+ ${cliArgs.length ? windowsBatchSet("OCX_CLI", cliArgs[0]!) : ""}\r
193
197
  ${windowsBatchSet("OCX_API_TOKEN_FILE", serviceApiTokenFilePath())}\r
194
198
  if "%OPENCODEX_API_AUTH_TOKEN%"=="" if exist "%OCX_API_TOKEN_FILE%" set /p OPENCODEX_API_AUTH_TOKEN=<"%OCX_API_TOKEN_FILE%"\r
195
199
  if not "%OCX_SHIM_BYPASS%"=="" goto run_codex\r
@@ -216,7 +220,7 @@ goto scan_codex_args\r
216
220
  setlocal\r
217
221
  ${windowsBatchSet(BUN_RUNTIME_SOURCE_ENV, bunRuntimeSource)}\r
218
222
  ${windowsBatchSet(BUN_RUNTIME_PATH_ENV, bunPath)}\r
219
- "%OCX_BUN%" "%OCX_CLI%" ensure >nul 2>nul\r
223
+ "%OCX_BUN%" ${cliArgs.length ? '"%OCX_CLI%" ' : ""}ensure >nul 2>nul\r
220
224
  if errorlevel 1 echo ${CODEX_SHIM_ENSURE_FAILED_DIAGNOSTIC} 1>&2\r
221
225
  endlocal\r
222
226
  :run_codex\r
@@ -232,6 +236,8 @@ export function buildWindowsPowerShellCodexShim(realCodexPath: string, bunPath:
232
236
  const internalCommands = CODEX_INTERNAL_COMMANDS.map(command => psString(command)).join(", ");
233
237
  const valueOptions = CODEX_GLOBAL_OPTIONS_WITH_VALUE.map(option => psString(option)).join(", ");
234
238
  const tokenFile = serviceApiTokenFilePath();
239
+ const cliArgs = selfLaunchArgv([], { sourceEntrypoint: cliPath, isStandaloneExecutable: bunRuntimeSource === "standalone" })
240
+ .map(arg => `${psString(arg)} `).join("");
235
241
  return `#!/usr/bin/env pwsh
236
242
  # ${SHIM_MARKER}
237
243
  $hadApiAuthToken = Test-Path Env:\\OPENCODEX_API_AUTH_TOKEN
@@ -263,7 +269,7 @@ if (-not $skipEnsure) {
263
269
  $ocxEnsureFailed = $false
264
270
  # Caught, not propagated: a throwing ensure used to escape this wrapper and Codex never
265
271
  # launched at all, which is a lockout produced by the autostart helper itself (#5261).
266
- try { & ${psString(bunPath)} ${psString(cliPath)} ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } }
272
+ try { & ${psString(bunPath)} ${cliArgs}ensure *> $null; if ($LASTEXITCODE -ne 0) { $ocxEnsureFailed = $true } }
267
273
  catch { $ocxEnsureFailed = $true }
268
274
  finally {
269
275
  if ($null -eq $priorRuntimeSource) { Remove-Item Env:\\${BUN_RUNTIME_SOURCE_ENV} -ErrorAction SilentlyContinue }
@@ -1,3 +1,4 @@
1
+ import { loadDiscoveredNativeModels } from "../codex/catalog/discovered-natives";
1
2
  import { setConfiguredNativeOpenAiModels } from "../codex/catalog/native-models";
2
3
  import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers-destination";
3
4
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -20,10 +21,11 @@ export function configuredNativeOpenAiModelIds(config: Pick<OcxConfig, "provider
20
21
 
21
22
  /**
22
23
  * Rebuild every process-local registry derived from a committed config: user price overlays and
23
- * configured native GPT models. Called wherever a loaded, persisted or reconciled config becomes
24
- * current, so the two never disagree about which config is live.
24
+ * configured/discovered native GPT models. Called wherever loaded, persisted or reconciled
25
+ * config becomes current, so catalog registration follows the active config and OpenCodex home.
25
26
  */
26
27
  export function refreshConfigDerivedRegistries(config: OcxConfig): void {
27
28
  refreshUserCostOverlays(config);
28
29
  setConfiguredNativeOpenAiModels(configuredNativeOpenAiModelIds(config));
30
+ loadDiscoveredNativeModels();
29
31
  }
@@ -455,6 +455,14 @@ function dropCodexSafetyBufferingError(value: unknown): string | null {
455
455
  return "schema_invalid: dropCodexSafetyBuffering: must be a boolean or omitted";
456
456
  }
457
457
 
458
+ function showCodexCreditsError(value: unknown): string | null {
459
+ const raw = rawConfigRecord(value);
460
+ if (!raw || !Object.hasOwn(raw, "showCodexCredits")) return null;
461
+ const enabled = raw.showCodexCredits;
462
+ if (enabled === undefined || typeof enabled === "boolean") return null;
463
+ return "schema_invalid: showCodexCredits: must be a boolean or omitted";
464
+ }
465
+
458
466
  function oauthOpenBrowserError(value: unknown): string | null {
459
467
  const raw = rawConfigRecord(value);
460
468
  if (!raw || !Object.hasOwn(raw, "oauthOpenBrowser")) return null;
@@ -645,6 +653,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
645
653
  ?? emptyCompletionRetryError(value)
646
654
  ?? dropCodexSafetyBufferingError(value)
647
655
  ?? oauthOpenBrowserError(value)
656
+ ?? showCodexCreditsError(value)
648
657
  ?? runtimeRoleError(value)
649
658
  ?? remoteGuiConfigError(value)
650
659
  ?? clientConnectionConfigError(value)
@@ -18,7 +18,7 @@ export function metricsExportEnabled(config: Pick<OcxConfig, "metricsExport">):
18
18
  }
19
19
 
20
20
  /**
21
- * Default cadence for the opt-in catalog auto-refresh (issue #3630): one converge pass
21
+ * Default cadence for catalog auto-refresh (issue #3630): one converge pass
22
22
  * per hour. Each pass spends a live /models call against every enabled provider, and
23
23
  * provider catalogs are themselves cached upstream for minutes, so an hour is fresh
24
24
  * enough for newly released models to appear without an `ocx sync`.
@@ -33,15 +33,17 @@ export const CATALOG_AUTO_REFRESH_DEFAULT_INTERVAL_MS: number = 60 * 60_000;
33
33
  export const CATALOG_AUTO_REFRESH_MIN_INTERVAL_MS: number = 15 * 60_000;
34
34
 
35
35
  /**
36
- * Opt-in master switch, read with the house `=== true` idiom so an absent key and a
37
- * malformed one both mean off. Pure on purpose: the scheduler calls this from a
36
+ * Enabled by default; explicit false or a zero cadence disables background work.
37
+ * Malformed enabled values still mean off. Pure on purpose: the scheduler calls this from a
38
38
  * dynamically imported context, so it takes an explicit config slice and reads nothing
39
39
  * global.
40
40
  */
41
41
  export function isCatalogAutoRefreshEnabled(
42
42
  config: Pick<OcxConfig, "catalogAutoRefresh">,
43
43
  ): boolean {
44
- return config.catalogAutoRefresh?.enabled === true;
44
+ const section = config.catalogAutoRefresh;
45
+ return (section?.enabled === undefined || section.enabled === true)
46
+ && section?.intervalMinutes !== 0;
45
47
  }
46
48
 
47
49
  /**
@@ -196,6 +196,7 @@ export const configSchema = z.object({
196
196
  // A malformed hand edit must not silently stop opening the browser: fall back
197
197
  // to undefined, which resolves to the historical auto-open behavior.
198
198
  oauthOpenBrowser: z.boolean().optional().catch(undefined),
199
+ showCodexCredits: z.boolean().optional().catch(false),
199
200
  openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(),
200
201
  // Invalid hand edits must not discard an otherwise usable config.
201
202
  googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined),
@@ -1058,6 +1058,7 @@ export const quotaResetNotifySchema = z.object({
1058
1058
 
1059
1059
  /**
1060
1060
  * Catalog auto-refresh section (issue #3630).
1061
+ * Missing section or enabled flag uses the hourly default-on scheduler.
1061
1062
  *
1062
1063
  * `.strict()` like its neighbour: a typo in an optional feature section should surface as a
1063
1064
  * rejected write rather than a silently ignored key that leaves the operator believing they