@bitkyc08/opencodex 2.27.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 (45) hide show
  1. package/gui/dist/assets/{index-7jlKgmJd.js → index-D2sP-biU.js} +11 -11
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/anthropic.ts +59 -0
  5. package/src/adapters/base.ts +2 -0
  6. package/src/adapters/google-antigravity-replay.ts +16 -8
  7. package/src/adapters/google.ts +21 -4
  8. package/src/adapters/openai-chat.ts +151 -54
  9. package/src/adapters/openai-responses.ts +37 -0
  10. package/src/cli/index.ts +19 -6
  11. package/src/codex/account-usability.ts +3 -0
  12. package/src/codex/auth-api.ts +22 -5
  13. package/src/codex/auth-context.ts +55 -2
  14. package/src/codex/catalog/metadata.ts +17 -3
  15. package/src/codex/catalog/native-models.ts +22 -14
  16. package/src/codex/catalog/sync.ts +57 -11
  17. package/src/codex/convergence.ts +61 -13
  18. package/src/codex/model-entitlements.ts +353 -0
  19. package/src/codex/quota.ts +28 -3
  20. package/src/codex/routing.ts +14 -8
  21. package/src/generated/compatibility-version.json +51 -39
  22. package/src/lib/destination-policy.ts +47 -0
  23. package/src/lib/shadow-call.ts +15 -0
  24. package/src/oauth/index.ts +33 -5
  25. package/src/oauth/store.ts +11 -5
  26. package/src/providers/fastwire.ts +39 -8
  27. package/src/providers/quota.ts +9 -2
  28. package/src/providers/registry.ts +74 -5
  29. package/src/providers/service-tier.ts +16 -8
  30. package/src/responses/parser.ts +3 -9
  31. package/src/responses/tool-search-compat.ts +301 -0
  32. package/src/router.ts +7 -0
  33. package/src/routing/capability.ts +26 -9
  34. package/src/routing/compatibility/behavior.ts +41 -3
  35. package/src/server/chat-native.ts +11 -2
  36. package/src/server/index.ts +54 -8
  37. package/src/server/management/agent-settings-routes.ts +16 -2
  38. package/src/server/request-log.ts +31 -0
  39. package/src/server/responses/compact.ts +54 -7
  40. package/src/server/responses/core.ts +246 -39
  41. package/src/server/responses/responses-field-backfill.ts +88 -6
  42. package/src/server/responses/terminal-guard.ts +10 -0
  43. package/src/server/responses-tool-search-repair.ts +217 -0
  44. package/src/server/system-env.ts +74 -5
  45. package/src/usage/log.ts +4 -0
@@ -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 {
@@ -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
+ }
@@ -186,7 +186,7 @@ function normalizeResetAt(value: unknown): number | undefined {
186
186
  }
187
187
 
188
188
  function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
189
- return [quota.weeklyPercent, quota.monthlyPercent]
189
+ return [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent]
190
190
  .some(value => typeof value === "number" && Number.isFinite(value));
191
191
  }
192
192
 
@@ -226,8 +226,14 @@ function snapshotHasMonthly(quota: Omit<StoredAccountQuota, "updatedAt">): boole
226
226
  return quota.monthlyPercent !== undefined || quota.monthlyResetAt !== undefined;
227
227
  }
228
228
 
229
+ function snapshotHasShort(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
230
+ return quota.shortPercent !== undefined
231
+ || quota.shortResetAt !== undefined
232
+ || quota.shortWindowSeconds !== undefined;
233
+ }
234
+
229
235
  function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
230
- return snapshotHasWeekly(quota) || snapshotHasMonthly(quota);
236
+ return snapshotHasWeekly(quota) || snapshotHasMonthly(quota) || snapshotHasShort(quota);
231
237
  }
232
238
  export function setAccountQuotaFromParsed(
233
239
  accountId: string,
@@ -246,6 +252,9 @@ export function setAccountQuotaFromParsed(
246
252
  if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
247
253
  if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
248
254
  if (existing?.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
255
+ if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
256
+ if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
257
+ if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
249
258
  next.resetCredits = quota.resetCredits;
250
259
  accountQuota.set(accountId, next);
251
260
  schedulePersistAccountQuotas();
@@ -270,12 +279,25 @@ export function setAccountQuotaFromParsed(
270
279
  // while silently dropping `monthlyIsPrimaryWindow` would look like tertiary-only data to
271
280
  // any future reader, and that failure would be invisible.
272
281
  if (quota.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
273
- } else if (snapshotHasWeekly(quota) && existing?.monthlyPercent !== undefined) {
282
+ } else if ((snapshotHasWeekly(quota) || snapshotHasShort(quota))
283
+ && existing?.monthlyPercent !== undefined) {
274
284
  next.monthlyPercent = existing.monthlyPercent;
275
285
  if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
276
286
  if (existing.monthlyIsPrimaryWindow === true) next.monthlyIsPrimaryWindow = true;
277
287
  }
278
288
 
289
+ if (snapshotHasShort(quota)) {
290
+ if (quota.shortPercent !== undefined) next.shortPercent = quota.shortPercent;
291
+ if (quota.shortResetAt !== undefined) next.shortResetAt = quota.shortResetAt;
292
+ if (quota.shortWindowSeconds !== undefined) next.shortWindowSeconds = quota.shortWindowSeconds;
293
+ } else {
294
+ // Header and reset-credit updates are partial snapshots. Preserve the last full WHAM
295
+ // burst tuple when those updates do not carry enough window metadata to replace it.
296
+ if (existing?.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
297
+ if (existing?.shortResetAt !== undefined) next.shortResetAt = existing.shortResetAt;
298
+ if (existing?.shortWindowSeconds !== undefined) next.shortWindowSeconds = existing.shortWindowSeconds;
299
+ }
300
+
279
301
  if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
280
302
  else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
281
303
 
@@ -369,6 +391,9 @@ export function updateAccountQuota(
369
391
  : {}),
370
392
  ...(existing?.weeklyResetAt !== undefined ? { weeklyResetAt: existing.weeklyResetAt } : {}),
371
393
  ...(existing?.monthlyResetAt !== undefined ? { monthlyResetAt: existing.monthlyResetAt } : {}),
394
+ ...(existing?.shortPercent !== undefined ? { shortPercent: existing.shortPercent } : {}),
395
+ ...(existing?.shortResetAt !== undefined ? { shortResetAt: existing.shortResetAt } : {}),
396
+ ...(existing?.shortWindowSeconds !== undefined ? { shortWindowSeconds: existing.shortWindowSeconds } : {}),
372
397
  ...(existing?.resetCredits !== undefined ? { resetCredits: existing.resetCredits } : {}),
373
398
  updatedAt: Date.now(),
374
399
  };
@@ -322,16 +322,22 @@ function deleteScopedHealth(accountId: string, scope: CodexQuotaScope): void {
322
322
  export function computeCodexUsageScore(quota: {
323
323
  weeklyPercent?: number;
324
324
  monthlyPercent?: number;
325
+ shortPercent?: number;
325
326
  } | null, plan?: unknown): number {
326
327
  if (!quota) return CODEX_UNKNOWN_USAGE_SCORE;
327
- if (isThirtyDayOnlyCodexPlan(plan)) {
328
- return typeof quota.monthlyPercent === "number" && Number.isFinite(quota.monthlyPercent)
329
- ? quota.monthlyPercent
330
- : CODEX_UNKNOWN_USAGE_SCORE;
331
- }
332
- const values = [quota.weeklyPercent, quota.monthlyPercent]
333
- .filter((value): value is number => typeof value === "number" && Number.isFinite(value));
334
- return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
328
+ const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
329
+ const longWindows = isThirtyDayOnlyCodexPlan(plan)
330
+ ? [quota.monthlyPercent]
331
+ : [quota.weeklyPercent, quota.monthlyPercent];
332
+ const knownLong = longWindows.filter(finite);
333
+ // The short burst window only REFINES a known long-window position; it cannot stand in for
334
+ // one. A snapshot carrying just `shortPercent: 0` would otherwise score a flat 0 and make an
335
+ // account whose weekly/monthly usage is entirely unverified look like the emptiest in the
336
+ // pool, so `pickLowestUsageAmong` would send every request to it. Unknown has to stay
337
+ // unknown until a governing window is actually observed.
338
+ if (knownLong.length === 0) return CODEX_UNKNOWN_USAGE_SCORE;
339
+ const values = finite(quota.shortPercent) ? [...knownLong, quota.shortPercent] : knownLong;
340
+ return Math.max(...values);
335
341
  }
336
342
 
337
343
  export function classifyCodexUpstreamOutcome(