@bitkyc08/opencodex 2.26.0 → 2.28.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 (83) hide show
  1. package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +60 -1
  5. package/src/adapters/base.ts +16 -2
  6. package/src/adapters/command-code.ts +4 -3
  7. package/src/adapters/cursor/cursor-errors.ts +15 -0
  8. package/src/adapters/cursor/live-transport.ts +14 -1
  9. package/src/adapters/google-antigravity-replay.ts +16 -8
  10. package/src/adapters/google.ts +22 -5
  11. package/src/adapters/openai-chat.ts +189 -60
  12. package/src/adapters/openai-responses.ts +37 -0
  13. package/src/adapters/tool-catalog-nudge.ts +1 -1
  14. package/src/bridge.ts +11 -5
  15. package/src/cli/doctor.ts +76 -0
  16. package/src/cli/help.ts +2 -0
  17. package/src/cli/index.ts +19 -6
  18. package/src/cli/models.ts +13 -6
  19. package/src/codex/account-usability.ts +3 -0
  20. package/src/codex/app-server-processes.ts +269 -37
  21. package/src/codex/auth-api.ts +22 -5
  22. package/src/codex/auth-context.ts +108 -3
  23. package/src/codex/catalog/aggregation.ts +3 -0
  24. package/src/codex/catalog/metadata.ts +17 -3
  25. package/src/codex/catalog/native-models.ts +22 -14
  26. package/src/codex/catalog/parsing.ts +20 -3
  27. package/src/codex/catalog/provider-fetch.ts +8 -0
  28. package/src/codex/catalog/sync.ts +63 -15
  29. package/src/codex/convergence.ts +61 -13
  30. package/src/codex/log-guard/path-safety.ts +52 -3
  31. package/src/codex/model-entitlements.ts +353 -0
  32. package/src/codex/native-profile-startup.ts +100 -2
  33. package/src/codex/quota.ts +28 -3
  34. package/src/codex/routing.ts +14 -8
  35. package/src/codex/user-identity.ts +21 -1
  36. package/src/config/provider-name.ts +24 -0
  37. package/src/config.ts +11 -24
  38. package/src/generated/compatibility-version.json +110 -70
  39. package/src/images/loop.ts +11 -4
  40. package/src/lib/destination-policy.ts +47 -0
  41. package/src/lib/shadow-call.ts +15 -0
  42. package/src/lib/state-store-registrations.ts +8 -2
  43. package/src/oauth/index.ts +33 -5
  44. package/src/oauth/store.ts +11 -5
  45. package/src/providers/antigravity-models.ts +70 -5
  46. package/src/providers/derive.ts +12 -2
  47. package/src/providers/fastwire.ts +39 -8
  48. package/src/providers/quota.ts +9 -2
  49. package/src/providers/registry.ts +120 -6
  50. package/src/providers/service-tier.ts +50 -15
  51. package/src/responses/parser.ts +59 -11
  52. package/src/responses/state.ts +162 -5
  53. package/src/responses/tool-search-compat.ts +301 -0
  54. package/src/router.ts +17 -3
  55. package/src/routing/capability.ts +26 -9
  56. package/src/routing/compatibility/behavior.ts +44 -6
  57. package/src/routing/profile.ts +1 -1
  58. package/src/server/chat-native.ts +11 -2
  59. package/src/server/index.ts +59 -8
  60. package/src/server/management/agent-settings-routes.ts +16 -2
  61. package/src/server/management/shared.ts +3 -1
  62. package/src/server/request-log.ts +31 -0
  63. package/src/server/responses/collaboration.ts +34 -9
  64. package/src/server/responses/compact.ts +54 -7
  65. package/src/server/responses/core.ts +259 -43
  66. package/src/server/responses/input-admission.ts +7 -2
  67. package/src/server/responses/responses-field-backfill.ts +88 -6
  68. package/src/server/responses/terminal-guard.ts +10 -0
  69. package/src/server/responses-tool-search-repair.ts +217 -0
  70. package/src/server/system-env.ts +74 -5
  71. package/src/service-manager-probe.ts +99 -0
  72. package/src/service.ts +86 -6
  73. package/src/tray/windows.ts +25 -5
  74. package/src/types/accounts.ts +37 -0
  75. package/src/types/config.ts +818 -0
  76. package/src/types/provider.ts +521 -0
  77. package/src/types/request.ts +358 -0
  78. package/src/types/tools.ts +131 -0
  79. package/src/types/wire.ts +80 -0
  80. package/src/types.ts +103 -1883
  81. package/src/usage/cost.ts +37 -1
  82. package/src/usage/log.ts +4 -0
  83. package/src/web-search/loop.ts +11 -4
@@ -66,6 +66,17 @@ import {
66
66
  supportedCodexReasoningEffortsFromObservedCatalog,
67
67
  } from "./catalog/effort";
68
68
  import { codexRuntimeStatePath, peekCodexRuntimeProcessCache } from "./runtime";
69
+ import { codexAccountNamespaceEntries, isMainCodexAccountTarget } from "./account-namespaces";
70
+ import { MAIN_CODEX_ACCOUNT_ID } from "./main-account";
71
+ import {
72
+ availableAccountGatedNativeModels,
73
+ isCodexModelEntitlementSnapshotCurrent,
74
+ resolveCodexModelEntitlements,
75
+ type CodexModelEntitlementSnapshot,
76
+ } from "./model-entitlements";
77
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
78
+ import { providerCodexAccountMode } from "../providers/registry";
79
+ import { OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
69
80
  import { withCatalogWriteSerialization } from "./catalog-write-serialization";
70
81
  import {
71
82
  publishHashedCodexCatalogBackup,
@@ -95,7 +106,7 @@ export interface CatalogWriteReceipt {
95
106
 
96
107
  export type CodexCatalogCommitResult =
97
108
  | { readonly kind: "committed"; readonly changed: boolean; readonly writes: CatalogWriteReceipt }
98
- | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" }
109
+ | { readonly kind: "stale"; readonly reason: "generation" | "home-selection" | "source-observation" | "process-local" | "target-identity" | "candidate-consumed" | "account-entitlement" }
99
110
  | { readonly kind: "refused"; readonly reason: "source-unreadable" | "source-ambiguous" | "target-unsafe" }
100
111
  | { readonly kind: "failed"; readonly surface: "disk"; readonly writes: CatalogWriteReceipt };
101
112
 
@@ -122,6 +133,7 @@ interface CandidateState {
122
133
  readonly legacyBackup?: PreparedCatalogFileWrite;
123
134
  readonly changed: boolean;
124
135
  readonly notices: readonly CatalogNotice[];
136
+ readonly modelEntitlements: CodexModelEntitlementSnapshot;
125
137
  }
126
138
 
127
139
  const candidateStates = new WeakMap<object, CandidateState>();
@@ -217,6 +229,7 @@ function prepareCatalog(
217
229
  baseline: ReadonlyMap<string, number>,
218
230
  baselineCatalogModels: readonly Readonly<Record<string, unknown>>[],
219
231
  degradedProviderNames: ReadonlySet<string>,
232
+ modelEntitlements: CodexModelEntitlementSnapshot,
220
233
  nativeRecoverySources: readonly (readonly RawEntry[])[] = [],
221
234
  observedAccountNativeEntries: readonly RawEntry[] = [],
222
235
  ): RawCatalog {
@@ -229,19 +242,46 @@ function prepareCatalog(
229
242
  const multiAgentMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2"
230
243
  ? config.multiAgentMode : "default";
231
244
  const exactComboSlugs = exactComboCatalogSlugs(config);
232
- const suppressedBareNativeSlugs = desktopAllowlistSuppressedNativeSlugs(config);
245
+ const bareEligibleAccountIds = providerCodexAccountMode(
246
+ OPENAI_CODEX_PROVIDER_ID,
247
+ config.providers[OPENAI_CODEX_PROVIDER_ID],
248
+ ) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
249
+ const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
250
+ modelEntitlements,
251
+ bareEligibleAccountIds,
252
+ );
253
+ const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
254
+ const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
255
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
256
+ ));
257
+ const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
258
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
259
+ ));
260
+ const suppressedBareNativeSlugs = new Set([
261
+ ...desktopAllowlistSuppressedNativeSlugs(config),
262
+ ...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)),
263
+ ]);
233
264
  const hasPhysicalComboProvider = Object.hasOwn(config.providers, COMBO_NAMESPACE);
234
265
  const enabledProviders = Object.entries(config.providers).filter(([, provider]) => provider.disabled !== true);
235
266
  const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
236
267
  const accountSelectors = shouldIncludeAccountBoundNativeOpenAi(config)
237
268
  ? visibleCodexAccountSelectors(config)
238
269
  : [];
239
- const accountNativeSlugs = accountSelectors.length > 0
240
- ? accountBoundNativeOpenAiSlugs(observedAccountNativeEntries)
241
- : [];
270
+ const accountTargets = new Map(codexAccountNamespaceEntries(config));
242
271
  const accountNativeSlugsBySelector = accountSelectors.length > 0
243
- ? accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)
272
+ ? new Map([...accountBoundNativeOpenAiSlugsBySelector(config, observedAccountNativeEntries)].map(([selector, slugs]) => {
273
+ const target = accountTargets.get(selector);
274
+ const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
275
+ const entitled = accountId ? modelEntitlements.modelsByAccount.get(accountId) : undefined;
276
+ const confirmed = accountId ? modelEntitlements.confirmedAccountIds.has(accountId) : false;
277
+ return [selector, slugs.filter(slug => (
278
+ !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || (confirmed && entitled?.has(slug) === true)
279
+ ))] as const;
280
+ }))
244
281
  : new Map<string, readonly string[]>();
282
+ const accountNativeSlugs = accountSelectors.length > 0
283
+ ? [...new Set([...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]))]
284
+ : [];
245
285
  // Unknown account-native ids have no safe bare/global identity. They are only projected through
246
286
  // selector-qualified rows when a live selector is configured.
247
287
  const observedNativeSlugs: string[] = [];
@@ -269,7 +309,7 @@ function prepareCatalog(
269
309
  ? []
270
310
  : buildCatalogEntriesFromObservedState({
271
311
  template: template ? JSON.parse(JSON.stringify(template)) : null,
272
- gptSlugs: NATIVE_OPENAI_MODELS,
312
+ gptSlugs: availableAccountNativeSlugs,
273
313
  goModels: [],
274
314
  featured,
275
315
  wsEnabled: websocketsEnabled(config),
@@ -314,7 +354,7 @@ function prepareCatalog(
314
354
  suppressedBareNativeSlugs,
315
355
  policy: {
316
356
  ...CANONICAL_NATIVE_CATALOG_CONTENT_POLICY,
317
- nativeBackfillSlugs: [...NATIVE_OPENAI_MODELS, ...observedNativeSlugs],
357
+ nativeBackfillSlugs: [...availableBareNativeSlugs, ...observedNativeSlugs],
318
358
  warningPolicy: "suppress",
319
359
  },
320
360
  });
@@ -356,11 +396,14 @@ export async function gatherCodexCatalogCandidate(
356
396
  const providerModelOutcomes: CatalogGatherProviderModelOutcome[] = [];
357
397
  const discoveryPolicies: CatalogProviderDiscoveryPolicySnapshot[] = [];
358
398
  providerGatherStarted = true;
359
- const routedModels = await gatherRoutedModelsForCatalogGather(snapshot.config, session, {
360
- providerAuthOutcomes: authOutcomes,
361
- providerModelOutcomes,
362
- discoveryPolicySnapshots: discoveryPolicies,
363
- });
399
+ const [routedModels, modelEntitlements] = await Promise.all([
400
+ gatherRoutedModelsForCatalogGather(snapshot.config, session, {
401
+ providerAuthOutcomes: authOutcomes,
402
+ providerModelOutcomes,
403
+ discoveryPolicySnapshots: discoveryPolicies,
404
+ }),
405
+ resolveCodexModelEntitlements(snapshot.config),
406
+ ]);
364
407
  const processLocal = processEvidence(source);
365
408
  const sourceEvidence = sealCatalogGatherEvidenceSession(session);
366
409
  if (!same(sourceEvidence.required, snapshot.sourceEvidence.required)) {
@@ -399,6 +442,7 @@ export async function gatherCodexCatalogCandidate(
399
442
  new Set(providerModelOutcomes
400
443
  .filter(outcome => outcome.state === "degraded")
401
444
  .map(outcome => outcome.provider)),
445
+ modelEntitlements,
402
446
  [
403
447
  catalogFrom(keyedBackupBytes)?.models ?? [],
404
448
  catalogFrom(legacyBackupBytes)?.models ?? [],
@@ -452,6 +496,7 @@ export async function gatherCodexCatalogCandidate(
452
496
  changed: Buffer.from(activeBytes ?? []).toString("utf8") !== preparedCatalogBytes
453
497
  || Buffer.from(cacheBytes ?? []).toString("utf8") !== preparedCacheBytes,
454
498
  notices: Object.freeze([...notices]),
499
+ modelEntitlements,
455
500
  });
456
501
  return { kind: "candidate", candidate };
457
502
  } catch (error) {
@@ -469,6 +514,9 @@ export async function gatherCodexCatalogCandidate(
469
514
  }
470
515
 
471
516
  function revalidateCandidate(state: CandidateState): CodexCatalogCommitResult | null {
517
+ if (!isCodexModelEntitlementSnapshotCurrent(state.modelEntitlements)) {
518
+ return { kind: "stale", reason: "account-entitlement" };
519
+ }
472
520
  let session: CatalogFilesystemEvidenceSession;
473
521
  let validatingTargets = false;
474
522
  try {
@@ -1,5 +1,5 @@
1
- import { realpathSync } from "node:fs";
2
- import { resolve, sep } from "node:path";
1
+ import { lstatSync, realpathSync } from "node:fs";
2
+ import { dirname, resolve, sep } from "node:path";
3
3
 
4
4
  import { samePathIdentity } from "../user-identity";
5
5
 
@@ -35,5 +35,54 @@ export function normalizeTrustedDarwinSystemAlias(path: string): string {
35
35
  * Arbitrary ancestor symlinks remain refused.
36
36
  */
37
37
  export function sameLogGuardPathIdentity(realPath: string, requestedPath: string): boolean {
38
- return samePathIdentity(realPath, normalizeTrustedDarwinSystemAlias(requestedPath));
38
+ const requested = normalizeTrustedDarwinSystemAlias(requestedPath);
39
+ if (samePathIdentity(realPath, requested)) return true;
40
+ return sameWindowsCanonicalPath(realPath, requested);
41
+ }
42
+
43
+ /**
44
+ * On Windows, is the difference between these two spellings the OS canonicalizing the
45
+ * request rather than a redirection?
46
+ *
47
+ * `realpathSync.native` expands 8.3 short components — the `RUNNER~1` form that appears
48
+ * throughout `%TEMP%` — so the canonical path and the requested path can disagree as
49
+ * strings while naming the same file. Reading that as an ancestor-symlink redirection made
50
+ * every Log Guard mutation refuse with `unsafe_path` on Windows, which is what the CI shards
51
+ * were reporting.
52
+ *
53
+ * The first version of this re-canonicalized the requested path and compared the two
54
+ * canonical forms. That was wrong, and the Windows shard proved it: the caller already
55
+ * passes `realpathSync.native(requested)` as `realPath`, so re-resolving the request
56
+ * produced the same value on BOTH sides and a symlinked database compared equal. The
57
+ * widening let through exactly what the guard exists to refuse.
58
+ *
59
+ * The comparison is therefore link-aware. A short-name expansion rewrites the spelling of
60
+ * components that are all still directories on the same chain, so it is enough to require
61
+ * that no component of the request is a link: with none present, any remaining difference
62
+ * is the OS's own canonical spelling. A symlink or junction anywhere in the chain fails
63
+ * closed as before.
64
+ */
65
+ function sameWindowsCanonicalPath(realPath: string, requestedPath: string): boolean {
66
+ if (process.platform !== "win32") return false;
67
+ try {
68
+ if (pathChainContainsLink(requestedPath)) return false;
69
+ return samePathIdentity(realPath, realpathSync.native(requestedPath));
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /** Is any component of this path a symlink or junction? Fails closed on an unreadable one. */
76
+ function pathChainContainsLink(path: string): boolean {
77
+ let current = resolve(path);
78
+ for (;;) {
79
+ try {
80
+ if (lstatSync(current).isSymbolicLink()) return true;
81
+ } catch {
82
+ return true;
83
+ }
84
+ const parent = dirname(current);
85
+ if (parent === current) return false;
86
+ current = parent;
87
+ }
39
88
  }
@@ -0,0 +1,353 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
+ import type { OcxConfig } from "../types";
4
+ import { isSelectableCodexPoolAccount } from "./account-id";
5
+ import { getValidCodexToken, readCodexAccountRecord } from "./account-store";
6
+ import { getMainAccountToken, MAIN_CODEX_ACCOUNT_ID } from "./main-account";
7
+ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
8
+
9
+ const CODEX_MODELS_URL = "https://chatgpt.com/backend-api/codex/models?client_version=0.0.0";
10
+ const MODEL_ROSTER_TTL_MS = 5 * 60_000;
11
+ const MODEL_ROSTER_FAILURE_TTL_MS = 15_000;
12
+ const MODEL_ROSTER_TIMEOUT_MS = 8_000;
13
+ const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024;
14
+ const MODEL_ROSTER_CACHE_MAX = 64;
15
+ const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:";
16
+
17
+ export interface CodexModelEntitlementCredentialSnapshot {
18
+ readonly accountId: string;
19
+ readonly accessToken: string;
20
+ readonly chatgptAccountId: string;
21
+ /** Stable local identity for rejecting a catalog commit after credential replacement. */
22
+ readonly credentialIdentity: string;
23
+ }
24
+
25
+ interface CachedAccountModels {
26
+ readonly credentialIdentity: string;
27
+ readonly expiresAt: number;
28
+ readonly models: ReadonlySet<string>;
29
+ readonly confirmed: boolean;
30
+ }
31
+
32
+ export interface CodexModelEntitlementSnapshot {
33
+ readonly modelsByAccount: ReadonlyMap<string, ReadonlySet<string>>;
34
+ readonly confirmedAccountIds: ReadonlySet<string>;
35
+ readonly credentialIdentities: ReadonlyMap<string, string>;
36
+ }
37
+
38
+ export interface CodexModelEntitlementResolveOptions {
39
+ readonly fetcher?: typeof fetch;
40
+ readonly now?: number;
41
+ /** Test-only credential seam; production callers enumerate local main + Pool credentials. */
42
+ readonly credentials?: readonly CodexModelEntitlementCredentialSnapshot[];
43
+ }
44
+
45
+ const accountModelsCache = new Map<string, CachedAccountModels>();
46
+ const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
47
+
48
+ /**
49
+ * Direct-caller entries are evicted separately from main/Pool entries.
50
+ *
51
+ * Direct keys are per-credential (`__direct_codex__:<hash>`) and unbounded in practice, while
52
+ * main/Pool keys are the evidence the CATALOG projects from. Sharing one LRU let 64 distinct
53
+ * Direct callers evict `__main__` and the Pool accounts, which makes the gated row vanish from
54
+ * the catalog until rediscovery — fail-closed flapping rather than a leak, but still a visible
55
+ * model disappearing for a reason the operator cannot see. Two budgets keep one class of caller
56
+ * from erasing the other's evidence.
57
+ */
58
+ function boundedCacheSet(accountId: string, value: CachedAccountModels): void {
59
+ accountModelsCache.delete(accountId);
60
+ accountModelsCache.set(accountId, value);
61
+ const isDirect = (key: string): boolean => key.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX);
62
+ const evictClass = (direct: boolean): void => {
63
+ let count = 0;
64
+ for (const key of accountModelsCache.keys()) if (isDirect(key) === direct) count += 1;
65
+ while (count > MODEL_ROSTER_CACHE_MAX) {
66
+ let oldest: string | undefined;
67
+ for (const key of accountModelsCache.keys()) {
68
+ if (isDirect(key) === direct) { oldest = key; break; }
69
+ }
70
+ if (oldest === undefined) break;
71
+ accountModelsCache.delete(oldest);
72
+ count -= 1;
73
+ }
74
+ };
75
+ evictClass(isDirect(accountId));
76
+ }
77
+
78
+ function currentCredentialIdentity(accountId: string): string | undefined {
79
+ if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
80
+ return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
81
+ }
82
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
83
+ const token = getMainAccountToken();
84
+ return token ? `main:${token.chatgptAccountId}` : undefined;
85
+ }
86
+ const record = readCodexAccountRecord(accountId);
87
+ if (!record?.credential || record.deletedAt != null) return undefined;
88
+ return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
89
+ }
90
+
91
+ async function accountCredentialSnapshot(accountId: string): Promise<CodexModelEntitlementCredentialSnapshot | null> {
92
+ if (accountId === MAIN_CODEX_ACCOUNT_ID) {
93
+ const token = getMainAccountToken();
94
+ return token
95
+ ? {
96
+ accountId,
97
+ accessToken: token.accessToken,
98
+ chatgptAccountId: token.chatgptAccountId,
99
+ credentialIdentity: `main:${token.chatgptAccountId}`,
100
+ }
101
+ : null;
102
+ }
103
+ try {
104
+ const token = await getValidCodexToken(accountId);
105
+ return {
106
+ accountId,
107
+ accessToken: token.accessToken,
108
+ chatgptAccountId: token.chatgptAccountId,
109
+ credentialIdentity: `pool:${token.generation}:${token.chatgptAccountId}`,
110
+ };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function parseAccountModels(text: string): ReadonlySet<string> | null {
117
+ try {
118
+ const payload = JSON.parse(text) as { models?: unknown };
119
+ if (!Array.isArray(payload.models)) return null;
120
+ const models = payload.models.flatMap(entry => {
121
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return [];
122
+ const row = entry as { slug?: unknown; supported_in_api?: unknown; visibility?: unknown };
123
+ if (typeof row.slug !== "string" || row.supported_in_api !== true || row.visibility === "hide") return [];
124
+ return [row.slug];
125
+ });
126
+ return new Set(models);
127
+ } catch {
128
+ return null;
129
+ }
130
+ }
131
+
132
+ async function fetchAccountModels(
133
+ credential: CodexModelEntitlementCredentialSnapshot,
134
+ fetcher: typeof fetch,
135
+ now: number,
136
+ ): Promise<CachedAccountModels> {
137
+ const controller = new AbortController();
138
+ const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS);
139
+ try {
140
+ const headers = new Headers({
141
+ Authorization: `Bearer ${credential.accessToken}`,
142
+ Accept: "application/json",
143
+ });
144
+ if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId);
145
+ const response = await fetcher(CODEX_MODELS_URL, {
146
+ headers,
147
+ redirect: "error",
148
+ signal: controller.signal,
149
+ });
150
+ const body = await readBoundedResponseBody(response, {
151
+ signal: controller.signal,
152
+ maxBytes: MODEL_ROSTER_MAX_BYTES,
153
+ fatalUtf8: true,
154
+ });
155
+ const models = response.ok && body.displaySafe && !body.truncated
156
+ ? parseAccountModels(body.text)
157
+ : null;
158
+ return {
159
+ credentialIdentity: credential.credentialIdentity,
160
+ expiresAt: now + (models ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS),
161
+ models: models ?? new Set(),
162
+ confirmed: models !== null,
163
+ };
164
+ } catch {
165
+ return {
166
+ credentialIdentity: credential.credentialIdentity,
167
+ expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS,
168
+ models: new Set(),
169
+ confirmed: false,
170
+ };
171
+ } finally {
172
+ clearTimeout(timer);
173
+ }
174
+ }
175
+
176
+ function directCallerCredential(headers: Headers): CodexModelEntitlementCredentialSnapshot | null {
177
+ const match = /^Bearer\s+(\S+)$/i.exec(headers.get("authorization")?.trim() ?? "");
178
+ if (!match) return null;
179
+ const accessToken = match[1]!;
180
+ const chatgptAccountId = headers.get("chatgpt-account-id")?.trim() ?? "";
181
+ const fingerprint = createHash("sha256")
182
+ .update(accessToken)
183
+ .update("\0")
184
+ .update(chatgptAccountId)
185
+ .digest("hex");
186
+ return {
187
+ accountId: `${DIRECT_CALLER_ACCOUNT_PREFIX}${fingerprint}`,
188
+ accessToken,
189
+ chatgptAccountId,
190
+ credentialIdentity: `direct:${fingerprint}`,
191
+ };
192
+ }
193
+
194
+ async function modelsForCredential(
195
+ credential: CodexModelEntitlementCredentialSnapshot,
196
+ fetcher: typeof fetch,
197
+ now: number,
198
+ ): Promise<CachedAccountModels> {
199
+ const cached = accountModelsCache.get(credential.accountId);
200
+ if (
201
+ cached
202
+ && cached.credentialIdentity === credential.credentialIdentity
203
+ && cached.expiresAt > now
204
+ ) return cached;
205
+
206
+ const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}`;
207
+ const existing = accountModelsFlights.get(flightKey);
208
+ if (existing) return existing;
209
+ const flight = fetchAccountModels(credential, fetcher, now)
210
+ .then(result => {
211
+ if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) {
212
+ boundedCacheSet(credential.accountId, result);
213
+ }
214
+ return result;
215
+ })
216
+ .finally(() => {
217
+ if (accountModelsFlights.get(flightKey) === flight) accountModelsFlights.delete(flightKey);
218
+ });
219
+ accountModelsFlights.set(flightKey, flight);
220
+ return flight;
221
+ }
222
+
223
+ function candidateAccountIds(config: Pick<OcxConfig, "codexAccounts">): string[] {
224
+ return [
225
+ MAIN_CODEX_ACCOUNT_ID,
226
+ ...(config.codexAccounts ?? [])
227
+ .filter(isSelectableCodexPoolAccount)
228
+ .map(account => account.id),
229
+ ];
230
+ }
231
+
232
+ /**
233
+ * Fetch the authenticated model roster for every locally usable Codex account.
234
+ *
235
+ * [Decision Log]
236
+ * - 목적과 의도: Account-gated native models must be advertised and selected only for accounts
237
+ * whose own authenticated upstream catalog confirms the model.
238
+ * - 기존 구현 및 제약 조건: The injected Codex catalog is static, while Pool may contain
239
+ * accounts with different entitlements. A global allowlist therefore exposed unusable rows.
240
+ * - 검토한 주요 대안: Infer access from plan labels, learn only after a failed prompt, or rewrite
241
+ * Daybreak to its current physical model.
242
+ * - 선택한 방식: Cache bounded authenticated `/models` rosters per credential generation and
243
+ * fail closed for unconfirmed accounts.
244
+ * - 다른 대안 대신 이 방식을 선택한 이유: Plan names do not prove grants, post-failure
245
+ * learning spends a real turn, and model rewriting changes the requested product identity.
246
+ * - 장점, 단점 및 영향: Catalog and routing share exact account evidence. Cold gated requests
247
+ * pay one bounded discovery call per account; discovery failure temporarily hides the gated row.
248
+ */
249
+ export async function resolveCodexModelEntitlements(
250
+ config: Pick<OcxConfig, "codexAccounts">,
251
+ options: CodexModelEntitlementResolveOptions = {},
252
+ ): Promise<CodexModelEntitlementSnapshot> {
253
+ const now = options.now ?? Date.now();
254
+ const fetcher = options.fetcher ?? fetch;
255
+ const credentials = options.credentials
256
+ ? [...options.credentials]
257
+ : (await Promise.all(candidateAccountIds(config).map(accountCredentialSnapshot)))
258
+ .filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
259
+ const results = await Promise.all(credentials.map(async credential => ({
260
+ credential,
261
+ result: await modelsForCredential(credential, fetcher, now),
262
+ })));
263
+ return {
264
+ modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])),
265
+ confirmedAccountIds: new Set(results.flatMap(({ credential, result }) => result.confirmed ? [credential.accountId] : [])),
266
+ credentialIdentities: new Map(results.map(({ credential }) => [credential.accountId, credential.credentialIdentity])),
267
+ };
268
+ }
269
+
270
+ /** Fail-closed entitlement check for a Direct request's own forwarded ChatGPT credential. */
271
+ export async function isDirectCallerEntitledToCodexModel(
272
+ headers: Headers,
273
+ modelId: string,
274
+ options: Pick<CodexModelEntitlementResolveOptions, "fetcher" | "now"> = {},
275
+ ): Promise<boolean> {
276
+ if (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return true;
277
+ const credential = directCallerCredential(headers);
278
+ if (!credential) return false;
279
+ const result = await modelsForCredential(
280
+ credential,
281
+ options.fetcher ?? fetch,
282
+ options.now ?? Date.now(),
283
+ );
284
+ return result.confirmed && result.models.has(modelId);
285
+ }
286
+
287
+ export function entitledCodexAccountIdsForModel(
288
+ snapshot: CodexModelEntitlementSnapshot,
289
+ modelId: string | undefined,
290
+ ): ReadonlySet<string> | undefined {
291
+ if (!modelId || !ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined;
292
+ return new Set([...snapshot.modelsByAccount].flatMap(([accountId, models]) => (
293
+ snapshot.confirmedAccountIds.has(accountId) && models.has(modelId) ? [accountId] : []
294
+ )));
295
+ }
296
+
297
+ export function availableAccountGatedNativeModels(
298
+ snapshot: CodexModelEntitlementSnapshot,
299
+ eligibleAccountIds?: ReadonlySet<string>,
300
+ ): ReadonlySet<string> {
301
+ return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => (
302
+ [...snapshot.modelsByAccount].some(([accountId, models]) => (
303
+ (!eligibleAccountIds || eligibleAccountIds.has(accountId))
304
+ && snapshot.confirmedAccountIds.has(accountId)
305
+ && models.has(modelId)
306
+ ))
307
+ )));
308
+ }
309
+
310
+ /** Synchronous projection for management/catalog readers after a discovery pass. */
311
+ export function cachedAvailableAccountGatedNativeModels(
312
+ now = Date.now(),
313
+ eligibleAccountIds?: ReadonlySet<string>,
314
+ ): ReadonlySet<string> {
315
+ return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => (
316
+ [...accountModelsCache].some(([accountId, entry]) => (
317
+ (!eligibleAccountIds || eligibleAccountIds.has(accountId))
318
+ && !accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
319
+ && entry.confirmed
320
+ && entry.expiresAt > now
321
+ && entry.models.has(modelId)
322
+ ))
323
+ )));
324
+ }
325
+
326
+ export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntitlementSnapshot): boolean {
327
+ for (const [accountId, identity] of snapshot.credentialIdentities) {
328
+ if (currentCredentialIdentity(accountId) !== identity) return false;
329
+ }
330
+ return true;
331
+ }
332
+
333
+ export function invalidateCodexModelEntitlementsForAccount(accountId: string | null | undefined): void {
334
+ if (accountId) accountModelsCache.delete(accountId);
335
+ }
336
+
337
+ export function resetCodexModelEntitlementCacheForTests(): void {
338
+ accountModelsCache.clear();
339
+ accountModelsFlights.clear();
340
+ }
341
+
342
+ export function seedCodexModelEntitlementsForTests(
343
+ accountId: string,
344
+ models: readonly string[],
345
+ now = Date.now(),
346
+ ): void {
347
+ boundedCacheSet(accountId, {
348
+ credentialIdentity: `test:${accountId}`,
349
+ expiresAt: now + MODEL_ROSTER_TTL_MS,
350
+ models: new Set(models),
351
+ confirmed: true,
352
+ });
353
+ }