@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830
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-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DPl4nBMA.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DNdRKXK9.js +0 -112
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
|
@@ -3,15 +3,213 @@ import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
|
3
3
|
import type { OcxConfig } from "../types";
|
|
4
4
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
5
5
|
import { getValidCodexToken, readCodexAccountRecord } from "./account-store";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getMainAccountToken,
|
|
8
|
+
getValidMainAccountToken,
|
|
9
|
+
MAIN_CODEX_ACCOUNT_ID,
|
|
10
|
+
type NativeMainRefreshDependencies,
|
|
11
|
+
} from "./main-account";
|
|
7
12
|
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
|
|
13
|
+
import { loadPersistedCodexRuntime } from "./runtime";
|
|
14
|
+
import { codexRuntimeStateEpoch } from "./runtime";
|
|
15
|
+
import upstreamModelsSnapshot from "./data/upstream-models.json";
|
|
8
16
|
|
|
9
|
-
const
|
|
17
|
+
const CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Upstream filters this roster by the client version it is told, and `client_version` is a
|
|
21
|
+
* required parameter — a measured `0.60.0` returns zero models where `0.142.2` returns five
|
|
22
|
+
* (devlog/_fin/260817_native_gpt56_1m_context/001_measurement_evidence.md). Asking as
|
|
23
|
+
* `0.0.0` therefore describes what a prehistoric client may use, and treating that as the
|
|
24
|
+
* account's entitlement hides models the account genuinely owns (#2886).
|
|
25
|
+
*
|
|
26
|
+
* A version must be supplied by the caller. There is deliberately no default: inventing one
|
|
27
|
+
* either manufactures a confirmed negative (the defect) or advertises models the installed
|
|
28
|
+
* runtime cannot drive (#2548, from the opposite side).
|
|
29
|
+
*/
|
|
30
|
+
function codexModelsUrl(clientVersion: string): string {
|
|
31
|
+
return `${CODEX_MODELS_ENDPOINT}?client_version=${encodeURIComponent(clientVersion)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A version string upstream can filter on. Rejects empty and the `0.0.0` placeholder. */
|
|
35
|
+
export function isUsableCodexClientVersion(value: string | null | undefined): value is string {
|
|
36
|
+
if (typeof value !== "string") return false;
|
|
37
|
+
const trimmed = value.trim();
|
|
38
|
+
if (!trimmed || trimmed === "0.0.0") return false;
|
|
39
|
+
// Bounded: the value reaches an outbound URL, and no real client version is this long.
|
|
40
|
+
if (trimmed.length > 64) return false;
|
|
41
|
+
if (!/^\d+(\.\d+)*([-+][0-9A-Za-z.-]+)?$/.test(trimmed)) return false;
|
|
42
|
+
// An all-zero numeric core is the same claim `0.0.0` makes — a client predating every gated
|
|
43
|
+
// model — so `0`, `0.0`, `00.0.0`, and `0.0.0-dev` must fail for the same reason, by value
|
|
44
|
+
// rather than by exact string.
|
|
45
|
+
const core = trimmed.split(/[-+]/, 1)[0]!;
|
|
46
|
+
if (core.split(".").every(segment => Number(segment) === 0)) return false;
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Highest `minimal_client_version` this build's bundled roster records for the models that
|
|
52
|
+
* are account-gated. Derived, not hardcoded: if the snapshot is refreshed with a model that
|
|
53
|
+
* requires a newer client, the floor follows it, so the two can never drift apart.
|
|
54
|
+
*
|
|
55
|
+
* Used only as the last tier of version precedence, for background work that has no request
|
|
56
|
+
* and no resolved runtime to speak for. It answers "what does this build believe the gated
|
|
57
|
+
* models need?", which is a claim the repository can actually substantiate.
|
|
58
|
+
*/
|
|
59
|
+
export function deriveGatedClientVersionFloor(
|
|
60
|
+
rows: ReadonlyArray<Record<string, unknown>>,
|
|
61
|
+
gatedSlugs: ReadonlySet<string> = ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
|
|
62
|
+
): string | null {
|
|
63
|
+
const floors = rows
|
|
64
|
+
.filter(row => typeof row.slug === "string" && gatedSlugs.has(row.slug))
|
|
65
|
+
.map(row => (typeof row.minimal_client_version === "string" ? row.minimal_client_version : null))
|
|
66
|
+
.filter((value): value is string => isUsableCodexClientVersion(value));
|
|
67
|
+
return floors.reduce<string | null>(
|
|
68
|
+
(best, candidate) => (best === null || compareClientVersions(candidate, best) > 0 ? candidate : best),
|
|
69
|
+
null,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fallback when the snapshot records no usable gated floor.
|
|
75
|
+
*
|
|
76
|
+
* Not every gated slug carries a `minimal_client_version` — `gpt-daybreak-blue-latest` has no
|
|
77
|
+
* row in the current snapshot at all — so the derivation can legitimately come back empty as the
|
|
78
|
+
* gated set changes. This is the last resort behind it, and it is still a version upstream can
|
|
79
|
+
* filter on rather than the placeholder that caused #2886.
|
|
80
|
+
*/
|
|
81
|
+
const GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK = "0.142.2";
|
|
82
|
+
|
|
83
|
+
export const GATED_MODEL_CLIENT_VERSION_FLOOR: string =
|
|
84
|
+
deriveGatedClientVersionFloor(
|
|
85
|
+
(upstreamModelsSnapshot as { models?: Array<Record<string, unknown>> }).models ?? [],
|
|
86
|
+
) ?? GATED_MODEL_CLIENT_VERSION_FLOOR_FALLBACK;
|
|
87
|
+
|
|
88
|
+
/** Numeric-segment comparison. Only used to pick the highest floor in a known-good set. */
|
|
89
|
+
function compareClientVersions(left: string, right: string): number {
|
|
90
|
+
const l = left.split(/[.+-]/).map(Number);
|
|
91
|
+
const r = right.split(/[.+-]/).map(Number);
|
|
92
|
+
for (let i = 0; i < Math.max(l.length, r.length); i += 1) {
|
|
93
|
+
const a = Number.isFinite(l[i]) ? l[i]! : 0;
|
|
94
|
+
const b = Number.isFinite(r[i]) ? r[i]! : 0;
|
|
95
|
+
if (a !== b) return a - b;
|
|
96
|
+
}
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Version authority, in precedence order:
|
|
102
|
+
*
|
|
103
|
+
* 1. the inbound request's own `client_version` — the only value certainly describing the
|
|
104
|
+
* client being answered;
|
|
105
|
+
* 2. the selected Codex runtime version, for background sync where no request exists.
|
|
106
|
+
* Retained sync refreshes runtime evidence before discovery, which is what makes this
|
|
107
|
+
* usable here; the persisted file itself carries no freshness guarantee.
|
|
108
|
+
* 3. the floor this build's own bundled roster records for the models being gated
|
|
109
|
+
* (`GATED_MODEL_CLIENT_VERSION_FLOOR`).
|
|
110
|
+
*
|
|
111
|
+
* Tier 3 exists because background catalog sync has no request and, on a host where Codex
|
|
112
|
+
* has never been resolved, no persisted runtime either — yet it is exactly the path that
|
|
113
|
+
* publishes account-confirmed native rows. Skipping discovery there suppressed the rows this
|
|
114
|
+
* fix is meant to restore. The floor is not invented: it is the version this build's own
|
|
115
|
+
* snapshot states the gated models require, so asking under it is the narrowest question
|
|
116
|
+
* that can still return them.
|
|
117
|
+
*
|
|
118
|
+
* There is deliberately no `0.0.0`-style fallback. A placeholder describes a client that
|
|
119
|
+
* predates every gated model, which is what made upstream answer with an empty roster and
|
|
120
|
+
* turned absent evidence into a manufactured confirmed negative (#2886).
|
|
121
|
+
*/
|
|
122
|
+
export function resolveCodexEntitlementClientVersion(
|
|
123
|
+
inbound?: string | null,
|
|
124
|
+
loadRuntime: () => { selectedVersion?: string | null } | null = loadPersistedCodexRuntime,
|
|
125
|
+
options: { readonly bypassRuntimeMemo?: boolean; readonly now?: number } = {},
|
|
126
|
+
): string {
|
|
127
|
+
if (isUsableCodexClientVersion(inbound)) return inbound.trim();
|
|
128
|
+
// The memo describes the real runtime file, so a caller supplying a different loader is asking
|
|
129
|
+
// a different question and must not be answered from it. Detected rather than left to the
|
|
130
|
+
// caller, because forgetting the flag would silently cross-answer.
|
|
131
|
+
const bypass = options.bypassRuntimeMemo === true || loadRuntime !== loadPersistedCodexRuntime;
|
|
132
|
+
const selected = bypass
|
|
133
|
+
? readRuntimeVersion(loadRuntime)
|
|
134
|
+
: memoizedPersistedRuntimeVersion(loadRuntime, options.now ?? Date.now());
|
|
135
|
+
return selected ?? GATED_MODEL_CLIENT_VERSION_FLOOR;
|
|
136
|
+
}
|
|
10
137
|
const MODEL_ROSTER_TTL_MS = 5 * 60_000;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How long a persisted-runtime version read is reused.
|
|
141
|
+
*
|
|
142
|
+
* Tier 2 of the version chain reads `codex-runtime.json` from disk, and it is consulted on
|
|
143
|
+
* every gated Direct authorization and every `/v1/models` resolution — including when the
|
|
144
|
+
* five-minute roster cache is hot, so the entitlement answer needs no I/O at all. Left
|
|
145
|
+
* unmemoized that is a synchronous `readFileSync` on the request path under concurrent gated
|
|
146
|
+
* traffic. `persistCodexRuntime` is the only writer, and it clears the runtime's own resolve
|
|
147
|
+
* cache; this window is short enough that a runtime switch is picked up promptly either way.
|
|
148
|
+
*/
|
|
149
|
+
const RUNTIME_VERSION_MEMO_MS = 5_000;
|
|
150
|
+
|
|
151
|
+
let runtimeVersionMemo: { at: number; epoch: number; version: string | null } | null = null;
|
|
152
|
+
|
|
153
|
+
function readRuntimeVersion(
|
|
154
|
+
loadRuntime: () => { selectedVersion?: string | null } | null,
|
|
155
|
+
): string | null {
|
|
156
|
+
try {
|
|
157
|
+
const value = loadRuntime()?.selectedVersion;
|
|
158
|
+
return isUsableCodexClientVersion(value) ? value.trim() : null;
|
|
159
|
+
} catch {
|
|
160
|
+
// An unreadable or malformed runtime file is an absent version, not a failure worth
|
|
161
|
+
// propagating into entitlement resolution.
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function memoizedPersistedRuntimeVersion(
|
|
167
|
+
loadRuntime: () => { selectedVersion?: string | null } | null,
|
|
168
|
+
now: number,
|
|
169
|
+
): string | null {
|
|
170
|
+
const epoch = codexRuntimeStateEpoch();
|
|
171
|
+
// Time bounds staleness; the epoch makes a runtime switch invalidate this immediately, so the
|
|
172
|
+
// window can never answer under the version that was just replaced.
|
|
173
|
+
if (
|
|
174
|
+
runtimeVersionMemo
|
|
175
|
+
&& runtimeVersionMemo.epoch === epoch
|
|
176
|
+
&& now - runtimeVersionMemo.at < RUNTIME_VERSION_MEMO_MS
|
|
177
|
+
) {
|
|
178
|
+
return runtimeVersionMemo.version;
|
|
179
|
+
}
|
|
180
|
+
const selected = readRuntimeVersion(loadRuntime);
|
|
181
|
+
runtimeVersionMemo = { at: now, epoch, version: selected };
|
|
182
|
+
return selected;
|
|
183
|
+
}
|
|
11
184
|
const MODEL_ROSTER_FAILURE_TTL_MS = 15_000;
|
|
12
185
|
const MODEL_ROSTER_TIMEOUT_MS = 8_000;
|
|
13
186
|
const MODEL_ROSTER_MAX_BYTES = 2 * 1024 * 1024;
|
|
14
187
|
const MODEL_ROSTER_CACHE_MAX = 64;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Versions retained per account.
|
|
191
|
+
*
|
|
192
|
+
* `client_version` arrives on the inbound `/v1/models` request, so making it part of the cache
|
|
193
|
+
* key handed callers a knob on key cardinality. With a flat per-key budget, one account cycling
|
|
194
|
+
* 64 versions filled its whole class and pushed other accounts' confirmed grants out — the same
|
|
195
|
+
* fail-closed catalog flapping the two-class budget exists to prevent, reached by a different
|
|
196
|
+
* axis. A handful of versions per account is all any real deployment needs (a client, a runtime,
|
|
197
|
+
* the floor), and the budget below counts ACCOUNTS so one noisy account cannot spend another's
|
|
198
|
+
* share.
|
|
199
|
+
*/
|
|
200
|
+
const MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX = 4;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Concurrent roster requests allowed per account.
|
|
204
|
+
*
|
|
205
|
+
* The cache is bounded on write, but an in-flight request is not a cache entry: distinct
|
|
206
|
+
* `client_version` values miss the flight key by design, so a caller cycling versions could open
|
|
207
|
+
* arbitrarily many concurrent upstream requests, each holding an eight-second timer. This bounds
|
|
208
|
+
* the concurrency itself. Exceeding it is reported as unconfirmed — the same fail-closed answer a
|
|
209
|
+
* discovery failure produces, and cheaper than either queueing or serving another version's
|
|
210
|
+
* roster.
|
|
211
|
+
*/
|
|
212
|
+
const MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX = 4;
|
|
15
213
|
const DIRECT_CALLER_ACCOUNT_PREFIX = "__direct_codex__:";
|
|
16
214
|
|
|
17
215
|
export interface CodexModelEntitlementCredentialSnapshot {
|
|
@@ -24,6 +222,12 @@ export interface CodexModelEntitlementCredentialSnapshot {
|
|
|
24
222
|
|
|
25
223
|
interface CachedAccountModels {
|
|
26
224
|
readonly credentialIdentity: string;
|
|
225
|
+
/**
|
|
226
|
+
* Client version this roster was fetched under. Upstream filters by it, so a roster is
|
|
227
|
+
* only an answer for that version — an entry fetched under one must never satisfy a read
|
|
228
|
+
* for another (#2886).
|
|
229
|
+
*/
|
|
230
|
+
readonly clientVersion: string;
|
|
27
231
|
readonly expiresAt: number;
|
|
28
232
|
readonly models: ReadonlySet<string>;
|
|
29
233
|
readonly confirmed: boolean;
|
|
@@ -37,7 +241,20 @@ export interface CodexModelEntitlementSnapshot {
|
|
|
37
241
|
|
|
38
242
|
export interface CodexModelEntitlementResolveOptions {
|
|
39
243
|
readonly fetcher?: typeof fetch;
|
|
244
|
+
/**
|
|
245
|
+
* Client version to ask upstream about. Absent means no trustworthy version was
|
|
246
|
+
* available, and discovery is skipped rather than asked under a placeholder.
|
|
247
|
+
*/
|
|
248
|
+
readonly clientVersion?: string | null;
|
|
249
|
+
/**
|
|
250
|
+
* Test-only seam for the persisted-runtime half of the version precedence chain, so a case
|
|
251
|
+
* can reach the no-trustworthy-version branch without depending on the host's own runtime
|
|
252
|
+
* state file.
|
|
253
|
+
*/
|
|
254
|
+
readonly loadPersistedRuntime?: () => { selectedVersion?: string | null } | null;
|
|
255
|
+
readonly nativeMainRefreshDependencies?: NativeMainRefreshDependencies;
|
|
40
256
|
readonly now?: number;
|
|
257
|
+
readonly signal?: AbortSignal;
|
|
41
258
|
/** Test-only credential seam; production callers enumerate local main + Pool credentials. */
|
|
42
259
|
readonly credentials?: readonly CodexModelEntitlementCredentialSnapshot[];
|
|
43
260
|
/** Test-only seam for proving lifecycle exclusions happen before credential reads. */
|
|
@@ -49,6 +266,22 @@ export interface CodexModelEntitlementResolveOptions {
|
|
|
49
266
|
const accountModelsCache = new Map<string, CachedAccountModels>();
|
|
50
267
|
const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
|
|
51
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Cache key. The roster is version-specific, so the version has to be part of the identity —
|
|
271
|
+
* with an account-only key, two versions in flight for one account race to overwrite each
|
|
272
|
+
* other, and the unversioned projection readers in `catalog/metadata.ts` then publish
|
|
273
|
+
* whichever landed last.
|
|
274
|
+
*/
|
|
275
|
+
function cacheKeyFor(accountId: string, clientVersion: string): string {
|
|
276
|
+
return `${accountId}\u0000${clientVersion}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Account component of a cache key, for eviction budgets and per-account invalidation. */
|
|
280
|
+
function accountIdOfCacheKey(key: string): string {
|
|
281
|
+
const separator = key.indexOf("\u0000");
|
|
282
|
+
return separator === -1 ? key : key.slice(0, separator);
|
|
283
|
+
}
|
|
284
|
+
|
|
52
285
|
/**
|
|
53
286
|
* Direct-caller entries are evicted separately from main/Pool entries.
|
|
54
287
|
*
|
|
@@ -60,23 +293,38 @@ const accountModelsFlights = new Map<string, Promise<CachedAccountModels>>();
|
|
|
60
293
|
* from erasing the other's evidence.
|
|
61
294
|
*/
|
|
62
295
|
function boundedCacheSet(accountId: string, value: CachedAccountModels): void {
|
|
63
|
-
|
|
64
|
-
accountModelsCache.
|
|
65
|
-
|
|
296
|
+
const key = cacheKeyFor(accountId, value.clientVersion);
|
|
297
|
+
accountModelsCache.delete(key);
|
|
298
|
+
accountModelsCache.set(key, value);
|
|
299
|
+
const isDirect = (candidate: string): boolean =>
|
|
300
|
+
accountIdOfCacheKey(candidate).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX);
|
|
301
|
+
|
|
302
|
+
// First, bound THIS account's versions, so a caller cycling client_version evicts only its
|
|
303
|
+
// own older entries and never reaches another account's evidence.
|
|
304
|
+
const ownKeys = [...accountModelsCache.keys()].filter(k => accountIdOfCacheKey(k) === accountId);
|
|
305
|
+
for (const stale of ownKeys.slice(0, Math.max(0, ownKeys.length - MODEL_ROSTER_VERSIONS_PER_ACCOUNT_MAX))) {
|
|
306
|
+
accountModelsCache.delete(stale);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Then bound the class by DISTINCT ACCOUNTS. Counting keys would let one account's versions
|
|
310
|
+
// consume the budget; counting accounts keeps each account's share independent of how many
|
|
311
|
+
// versions any other account is using.
|
|
66
312
|
const evictClass = (direct: boolean): void => {
|
|
67
|
-
|
|
68
|
-
for (const key of accountModelsCache.keys())
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
313
|
+
const accountsInOrder: string[] = [];
|
|
314
|
+
for (const key of accountModelsCache.keys()) {
|
|
315
|
+
if (isDirect(key) !== direct) continue;
|
|
316
|
+
const account = accountIdOfCacheKey(key);
|
|
317
|
+
if (!accountsInOrder.includes(account)) accountsInOrder.push(account);
|
|
318
|
+
}
|
|
319
|
+
// Never evict the account just written, even if it is the least-recently-inserted.
|
|
320
|
+
for (const victim of accountsInOrder.slice(0, Math.max(0, accountsInOrder.length - MODEL_ROSTER_CACHE_MAX))) {
|
|
321
|
+
if (victim === accountId) continue;
|
|
322
|
+
for (const key of [...accountModelsCache.keys()]) {
|
|
323
|
+
if (accountIdOfCacheKey(key) === victim) accountModelsCache.delete(key);
|
|
73
324
|
}
|
|
74
|
-
if (oldest === undefined) break;
|
|
75
|
-
accountModelsCache.delete(oldest);
|
|
76
|
-
count -= 1;
|
|
77
325
|
}
|
|
78
326
|
};
|
|
79
|
-
evictClass(
|
|
327
|
+
evictClass(accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX));
|
|
80
328
|
}
|
|
81
329
|
|
|
82
330
|
function currentCredentialIdentity(accountId: string): string | undefined {
|
|
@@ -92,9 +340,15 @@ function currentCredentialIdentity(accountId: string): string | undefined {
|
|
|
92
340
|
return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
|
|
93
341
|
}
|
|
94
342
|
|
|
95
|
-
async function accountCredentialSnapshot(
|
|
343
|
+
async function accountCredentialSnapshot(
|
|
344
|
+
accountId: string,
|
|
345
|
+
options: Pick<CodexModelEntitlementResolveOptions, "nativeMainRefreshDependencies" | "signal"> = {},
|
|
346
|
+
): Promise<CodexModelEntitlementCredentialSnapshot | null> {
|
|
96
347
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
97
|
-
const token =
|
|
348
|
+
const token = await getValidMainAccountToken({
|
|
349
|
+
signal: options.signal,
|
|
350
|
+
...(options.nativeMainRefreshDependencies ?? {}),
|
|
351
|
+
});
|
|
98
352
|
return token
|
|
99
353
|
? {
|
|
100
354
|
accountId,
|
|
@@ -137,6 +391,7 @@ async function fetchAccountModels(
|
|
|
137
391
|
credential: CodexModelEntitlementCredentialSnapshot,
|
|
138
392
|
fetcher: typeof fetch,
|
|
139
393
|
now: number,
|
|
394
|
+
clientVersion: string,
|
|
140
395
|
): Promise<CachedAccountModels> {
|
|
141
396
|
const controller = new AbortController();
|
|
142
397
|
const timer = setTimeout(() => controller.abort(new DOMException("Codex model discovery timed out", "TimeoutError")), MODEL_ROSTER_TIMEOUT_MS);
|
|
@@ -146,7 +401,7 @@ async function fetchAccountModels(
|
|
|
146
401
|
Accept: "application/json",
|
|
147
402
|
});
|
|
148
403
|
if (credential.chatgptAccountId) headers.set("ChatGPT-Account-Id", credential.chatgptAccountId);
|
|
149
|
-
const response = await fetcher(
|
|
404
|
+
const response = await fetcher(codexModelsUrl(clientVersion), {
|
|
150
405
|
headers,
|
|
151
406
|
redirect: "error",
|
|
152
407
|
signal: controller.signal,
|
|
@@ -161,6 +416,7 @@ async function fetchAccountModels(
|
|
|
161
416
|
: null;
|
|
162
417
|
return {
|
|
163
418
|
credentialIdentity: credential.credentialIdentity,
|
|
419
|
+
clientVersion,
|
|
164
420
|
expiresAt: now + (models ? MODEL_ROSTER_TTL_MS : MODEL_ROSTER_FAILURE_TTL_MS),
|
|
165
421
|
models: models ?? new Set(),
|
|
166
422
|
confirmed: models !== null,
|
|
@@ -168,6 +424,7 @@ async function fetchAccountModels(
|
|
|
168
424
|
} catch {
|
|
169
425
|
return {
|
|
170
426
|
credentialIdentity: credential.credentialIdentity,
|
|
427
|
+
clientVersion,
|
|
171
428
|
expiresAt: now + MODEL_ROSTER_FAILURE_TTL_MS,
|
|
172
429
|
models: new Set(),
|
|
173
430
|
confirmed: false,
|
|
@@ -199,18 +456,34 @@ async function modelsForCredential(
|
|
|
199
456
|
credential: CodexModelEntitlementCredentialSnapshot,
|
|
200
457
|
fetcher: typeof fetch,
|
|
201
458
|
now: number,
|
|
459
|
+
clientVersion: string,
|
|
202
460
|
): Promise<CachedAccountModels> {
|
|
203
|
-
const cached = accountModelsCache.get(credential.accountId);
|
|
461
|
+
const cached = accountModelsCache.get(cacheKeyFor(credential.accountId, clientVersion));
|
|
204
462
|
if (
|
|
205
463
|
cached
|
|
206
464
|
&& cached.credentialIdentity === credential.credentialIdentity
|
|
207
465
|
&& cached.expiresAt > now
|
|
208
466
|
) return cached;
|
|
209
467
|
|
|
210
|
-
const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}`;
|
|
468
|
+
const flightKey = `${credential.accountId}\u0000${credential.credentialIdentity}\u0000${clientVersion}`;
|
|
211
469
|
const existing = accountModelsFlights.get(flightKey);
|
|
212
470
|
if (existing) return existing;
|
|
213
|
-
|
|
471
|
+
|
|
472
|
+
// Bound concurrency per account before opening another upstream request.
|
|
473
|
+
let liveForAccount = 0;
|
|
474
|
+
for (const key of accountModelsFlights.keys()) {
|
|
475
|
+
if (accountIdOfCacheKey(key) === credential.accountId) liveForAccount += 1;
|
|
476
|
+
}
|
|
477
|
+
if (liveForAccount >= MODEL_ROSTER_FLIGHTS_PER_ACCOUNT_MAX) {
|
|
478
|
+
return {
|
|
479
|
+
credentialIdentity: credential.credentialIdentity,
|
|
480
|
+
clientVersion,
|
|
481
|
+
expiresAt: now,
|
|
482
|
+
models: new Set(),
|
|
483
|
+
confirmed: false,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const flight = fetchAccountModels(credential, fetcher, now, clientVersion)
|
|
214
487
|
.then(result => {
|
|
215
488
|
if (currentCredentialIdentity(credential.accountId) === credential.credentialIdentity) {
|
|
216
489
|
boundedCacheSet(credential.accountId, result);
|
|
@@ -256,16 +529,20 @@ export async function resolveCodexModelEntitlements(
|
|
|
256
529
|
): Promise<CodexModelEntitlementSnapshot> {
|
|
257
530
|
const now = options.now ?? Date.now();
|
|
258
531
|
const fetcher = options.fetcher ?? fetch;
|
|
532
|
+
const clientVersion = resolveCodexEntitlementClientVersion(
|
|
533
|
+
options.clientVersion,
|
|
534
|
+
options.loadPersistedRuntime ?? loadPersistedCodexRuntime,
|
|
535
|
+
);
|
|
259
536
|
const allowedAccountIds = candidateAccountIds(config)
|
|
260
537
|
.filter(accountId => !options.excludeAccountIds?.has(accountId));
|
|
261
538
|
const credentialSnapshot = options.credentialSnapshot ?? accountCredentialSnapshot;
|
|
262
539
|
const credentials = options.credentials
|
|
263
540
|
? [...options.credentials].filter(credential => !options.excludeAccountIds?.has(credential.accountId))
|
|
264
|
-
: (await Promise.all(allowedAccountIds.map(credentialSnapshot)))
|
|
541
|
+
: (await Promise.all(allowedAccountIds.map(accountId => credentialSnapshot(accountId, options))))
|
|
265
542
|
.filter((value): value is CodexModelEntitlementCredentialSnapshot => value !== null);
|
|
266
543
|
const results = await Promise.all(credentials.map(async credential => ({
|
|
267
544
|
credential,
|
|
268
|
-
result: await modelsForCredential(credential, fetcher, now),
|
|
545
|
+
result: await modelsForCredential(credential, fetcher, now, clientVersion),
|
|
269
546
|
})));
|
|
270
547
|
return {
|
|
271
548
|
modelsByAccount: new Map(results.map(({ credential, result }) => [credential.accountId, result.models])),
|
|
@@ -278,15 +555,17 @@ export async function resolveCodexModelEntitlements(
|
|
|
278
555
|
export async function isDirectCallerEntitledToCodexModel(
|
|
279
556
|
headers: Headers,
|
|
280
557
|
modelId: string,
|
|
281
|
-
options: Pick<CodexModelEntitlementResolveOptions, "fetcher" | "now"> = {},
|
|
558
|
+
options: Pick<CodexModelEntitlementResolveOptions, "fetcher" | "now" | "clientVersion"> = {},
|
|
282
559
|
): Promise<boolean> {
|
|
283
560
|
if (!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(modelId)) return true;
|
|
284
561
|
const credential = directCallerCredential(headers);
|
|
285
562
|
if (!credential) return false;
|
|
563
|
+
const clientVersion = resolveCodexEntitlementClientVersion(options.clientVersion);
|
|
286
564
|
const result = await modelsForCredential(
|
|
287
565
|
credential,
|
|
288
566
|
options.fetcher ?? fetch,
|
|
289
567
|
options.now ?? Date.now(),
|
|
568
|
+
clientVersion,
|
|
290
569
|
);
|
|
291
570
|
return result.confirmed && result.models.has(modelId);
|
|
292
571
|
}
|
|
@@ -318,11 +597,22 @@ export function availableAccountGatedNativeModels(
|
|
|
318
597
|
export function cachedAvailableAccountGatedNativeModels(
|
|
319
598
|
now = Date.now(),
|
|
320
599
|
eligibleAccountIds?: ReadonlySet<string>,
|
|
600
|
+
clientVersion?: string | null,
|
|
321
601
|
): ReadonlySet<string> {
|
|
602
|
+
// Entries fetched under a different client version answer a different question, so a caller
|
|
603
|
+
// that knows which version it is projecting for must say so — otherwise a newer client's
|
|
604
|
+
// roster leaks into an older client's projection (#2548 from the opposite direction).
|
|
605
|
+
//
|
|
606
|
+
// Omitting the argument deliberately does NOT filter. This is a synchronous read of
|
|
607
|
+
// whatever discovery already proved, and its callers (catalog metadata) are not
|
|
608
|
+
// request-scoped: resolving a version here would silently discard every entry fetched under
|
|
609
|
+
// a different one, which is a suppression this function has no evidence to justify.
|
|
610
|
+
const version = isUsableCodexClientVersion(clientVersion) ? clientVersion.trim() : null;
|
|
322
611
|
return new Set([...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(modelId => (
|
|
323
612
|
[...accountModelsCache].some(([accountId, entry]) => (
|
|
324
|
-
(!eligibleAccountIds || eligibleAccountIds.has(accountId))
|
|
325
|
-
&& !accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
|
|
613
|
+
(!eligibleAccountIds || eligibleAccountIds.has(accountIdOfCacheKey(accountId)))
|
|
614
|
+
&& !accountIdOfCacheKey(accountId).startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)
|
|
615
|
+
&& (version === null || entry.clientVersion === version)
|
|
326
616
|
&& entry.confirmed
|
|
327
617
|
&& entry.expiresAt > now
|
|
328
618
|
&& entry.models.has(modelId)
|
|
@@ -338,21 +628,43 @@ export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntit
|
|
|
338
628
|
}
|
|
339
629
|
|
|
340
630
|
export function invalidateCodexModelEntitlementsForAccount(accountId: string | null | undefined): void {
|
|
341
|
-
if (accountId)
|
|
631
|
+
if (!accountId) return;
|
|
632
|
+
// Every version's entry for this account, since a credential change invalidates the
|
|
633
|
+
// account's entitlement evidence regardless of which client version asked for it.
|
|
634
|
+
for (const key of [...accountModelsCache.keys()]) {
|
|
635
|
+
if (accountIdOfCacheKey(key) === accountId) accountModelsCache.delete(key);
|
|
636
|
+
}
|
|
342
637
|
}
|
|
343
638
|
|
|
344
639
|
export function resetCodexModelEntitlementCacheForTests(): void {
|
|
345
640
|
accountModelsCache.clear();
|
|
346
641
|
accountModelsFlights.clear();
|
|
642
|
+
runtimeVersionMemo = null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Test-only seam for the memoized tier-2 read.
|
|
647
|
+
*
|
|
648
|
+
* The memo is deliberately reachable only through the DEFAULT loader (a supplied loader is
|
|
649
|
+
* auto-bypassed, so it cannot be cross-answered), which leaves no way to observe memo behavior
|
|
650
|
+
* from a test without either touching the real state file or exposing this.
|
|
651
|
+
*/
|
|
652
|
+
export function memoizeRuntimeVersionForTests(
|
|
653
|
+
loadRuntime: () => { selectedVersion?: string | null } | null,
|
|
654
|
+
now: number,
|
|
655
|
+
): string | null {
|
|
656
|
+
return memoizedPersistedRuntimeVersion(loadRuntime, now);
|
|
347
657
|
}
|
|
348
658
|
|
|
349
659
|
export function seedCodexModelEntitlementsForTests(
|
|
350
660
|
accountId: string,
|
|
351
661
|
models: readonly string[],
|
|
352
662
|
now = Date.now(),
|
|
663
|
+
clientVersion = "0.146.0",
|
|
353
664
|
): void {
|
|
354
665
|
boundedCacheSet(accountId, {
|
|
355
666
|
credentialIdentity: `test:${accountId}`,
|
|
667
|
+
clientVersion,
|
|
356
668
|
expiresAt: now + MODEL_ROSTER_TTL_MS,
|
|
357
669
|
models: new Set(models),
|
|
358
670
|
confirmed: true,
|