@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.
- package/gui/dist/assets/{index-RL6b1bTV.js → index-D2sP-biU.js} +14 -14
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +60 -1
- package/src/adapters/base.ts +16 -2
- package/src/adapters/command-code.ts +4 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/live-transport.ts +14 -1
- package/src/adapters/google-antigravity-replay.ts +16 -8
- package/src/adapters/google.ts +22 -5
- package/src/adapters/openai-chat.ts +189 -60
- package/src/adapters/openai-responses.ts +37 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/bridge.ts +11 -5
- package/src/cli/doctor.ts +76 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +19 -6
- package/src/cli/models.ts +13 -6
- package/src/codex/account-usability.ts +3 -0
- package/src/codex/app-server-processes.ts +269 -37
- package/src/codex/auth-api.ts +22 -5
- package/src/codex/auth-context.ts +108 -3
- package/src/codex/catalog/aggregation.ts +3 -0
- package/src/codex/catalog/metadata.ts +17 -3
- package/src/codex/catalog/native-models.ts +22 -14
- package/src/codex/catalog/parsing.ts +20 -3
- package/src/codex/catalog/provider-fetch.ts +8 -0
- package/src/codex/catalog/sync.ts +63 -15
- package/src/codex/convergence.ts +61 -13
- package/src/codex/log-guard/path-safety.ts +52 -3
- package/src/codex/model-entitlements.ts +353 -0
- package/src/codex/native-profile-startup.ts +100 -2
- package/src/codex/quota.ts +28 -3
- package/src/codex/routing.ts +14 -8
- package/src/codex/user-identity.ts +21 -1
- package/src/config/provider-name.ts +24 -0
- package/src/config.ts +11 -24
- package/src/generated/compatibility-version.json +110 -70
- package/src/images/loop.ts +11 -4
- package/src/lib/destination-policy.ts +47 -0
- package/src/lib/shadow-call.ts +15 -0
- package/src/lib/state-store-registrations.ts +8 -2
- package/src/oauth/index.ts +33 -5
- package/src/oauth/store.ts +11 -5
- package/src/providers/antigravity-models.ts +70 -5
- package/src/providers/derive.ts +12 -2
- package/src/providers/fastwire.ts +39 -8
- package/src/providers/quota.ts +9 -2
- package/src/providers/registry.ts +120 -6
- package/src/providers/service-tier.ts +50 -15
- package/src/responses/parser.ts +59 -11
- package/src/responses/state.ts +162 -5
- package/src/responses/tool-search-compat.ts +301 -0
- package/src/router.ts +17 -3
- package/src/routing/capability.ts +26 -9
- package/src/routing/compatibility/behavior.ts +44 -6
- package/src/routing/profile.ts +1 -1
- package/src/server/chat-native.ts +11 -2
- package/src/server/index.ts +59 -8
- package/src/server/management/agent-settings-routes.ts +16 -2
- package/src/server/management/shared.ts +3 -1
- package/src/server/request-log.ts +31 -0
- package/src/server/responses/collaboration.ts +34 -9
- package/src/server/responses/compact.ts +54 -7
- package/src/server/responses/core.ts +259 -43
- package/src/server/responses/input-admission.ts +7 -2
- package/src/server/responses/responses-field-backfill.ts +88 -6
- package/src/server/responses/terminal-guard.ts +10 -0
- package/src/server/responses-tool-search-repair.ts +217 -0
- package/src/server/system-env.ts +74 -5
- package/src/service-manager-probe.ts +99 -0
- package/src/service.ts +86 -6
- package/src/tray/windows.ts +25 -5
- package/src/types/accounts.ts +37 -0
- package/src/types/config.ts +818 -0
- package/src/types/provider.ts +521 -0
- package/src/types/request.ts +358 -0
- package/src/types/tools.ts +131 -0
- package/src/types/wire.ts +80 -0
- package/src/types.ts +103 -1883
- package/src/usage/cost.ts +37 -1
- package/src/usage/log.ts +4 -0
- package/src/web-search/loop.ts +11 -4
package/src/codex/convergence.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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: [...
|
|
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
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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
|
+
}
|