@bitkyc08/opencodex 2.55.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +34 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +147 -1
- package/src/usage/summary.ts +171 -21
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex V2 conversation lineage: root, parent, child, grandchild (#4546, wp8).
|
|
3
|
+
*
|
|
4
|
+
* The pool affinity key used to prefer `x-codex-parent-thread-id`, which collapsed two different
|
|
5
|
+
* identities into one map. A root bound under `app:HMAC(session, thread)` while every child bound
|
|
6
|
+
* under the RAW parent id, so siblings shared one binding entry unrelated to the root's, and a
|
|
7
|
+
* grandchild keyed on its own parent landed on a key nobody had ever bound. The proxy therefore
|
|
8
|
+
* treated one workflow as unrelated strangers even while the provider saw a single prompt-cache
|
|
9
|
+
* family.
|
|
10
|
+
*
|
|
11
|
+
* This module records the real relation -- each thread's own conversation key, its immediate
|
|
12
|
+
* parent's thread id, and the transitive root -- scoped per authenticated caller, bounded, and
|
|
13
|
+
* process-local like the binding map it feeds.
|
|
14
|
+
*
|
|
15
|
+
* What it is for, and what it is not for:
|
|
16
|
+
*
|
|
17
|
+
* - FIRST PLACEMENT. A child with no binding of its own may start where its family is already
|
|
18
|
+
* warm; see `pickLineageServingAccount` in ./routing. Once bound, the child is an ordinary
|
|
19
|
+
* binding, so a later move of the parent does not drag it.
|
|
20
|
+
* - COST ATTRIBUTION. {@link codexThreadLineageLookup} and {@link codexLineageRootForRequest}
|
|
21
|
+
* answer which root workflow a conversation belongs to, so a grandchild's spend aggregates
|
|
22
|
+
* onto the root. No budget is implemented here.
|
|
23
|
+
* - WORKER CLASSIFICATION, exposed but not rewired. Admission classifies header-only today: a
|
|
24
|
+
* request naming a parent plus a distinct `thread-id` is worker traffic, and a request without
|
|
25
|
+
* `thread-id` is interactive even when it belongs to a recorded fan-out.
|
|
26
|
+
* {@link codexLineageWorkflowLane} is the lineage-backed answer a later lane consumes.
|
|
27
|
+
*
|
|
28
|
+
* Scope is an HMAC of the caller's Authorization header under a process-local key, the same
|
|
29
|
+
* posture as the affinity key itself. Two callers presenting identical thread ids can never read
|
|
30
|
+
* each other's lineage, and no raw identifier or durable hash is stored.
|
|
31
|
+
*
|
|
32
|
+
* LIFETIME, stated plainly because the word "affinity" invites the opposite assumption: none of
|
|
33
|
+
* this survives the process. The binding map is in memory, and the HMAC key above is fresh random
|
|
34
|
+
* bytes taken at module load, so a restart does not merely forget the table -- it makes yesterday's
|
|
35
|
+
* keys unreproducible. This is a warm-start hint for the life of one proxy process, never durable
|
|
36
|
+
* account ownership, and nothing here should be read as a promise to a conversation that outlives
|
|
37
|
+
* a restart.
|
|
38
|
+
*
|
|
39
|
+
* The one upgrade that is neither a fresh start nor an untouched process is a code swap under a
|
|
40
|
+
* live conversation, where the binding map is still populated with entries made under the
|
|
41
|
+
* pre-#4546 RAW parent key. Silently rebinding those cold is the exact defect this module exists
|
|
42
|
+
* to prevent, so a request that names a parent carries {@link CodexThreadLineage.legacyConversationKey}
|
|
43
|
+
* -- the key the old rule would have returned -- and routing adopts that binding once under the new
|
|
44
|
+
* key and retires the legacy entry. It is a one-way migration, not a second lookup path.
|
|
45
|
+
*/
|
|
46
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
47
|
+
import { retainedUtf8Bytes } from "../lib/admission";
|
|
48
|
+
|
|
49
|
+
const CODEX_LINEAGE_COMPONENT_MAX_BYTES = 512;
|
|
50
|
+
const CODEX_LINEAGE_KEY = randomBytes(32);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Mirrors `CODEX_THREAD_AFFINITY_IDLE_TTL_MS` in ./routing. Deliberately duplicated rather than
|
|
54
|
+
* imported: lineage is a leaf module, and a value import from the routing module that consumes it
|
|
55
|
+
* would turn an erased type-only edge into a real cycle.
|
|
56
|
+
*/
|
|
57
|
+
export const CODEX_LINEAGE_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
58
|
+
/** Records per authenticated scope, on the order of the binding map's own 2048-entry cap. */
|
|
59
|
+
export const CODEX_LINEAGE_MAX_ENTRIES = 2048;
|
|
60
|
+
/** Distinct authenticated callers retained. Without this the scope map is the unbounded one. */
|
|
61
|
+
export const CODEX_LINEAGE_MAX_SCOPES = 64;
|
|
62
|
+
/** Sibling hints kept per parent, most recently used first. */
|
|
63
|
+
export const CODEX_LINEAGE_MAX_SIBLINGS = 8;
|
|
64
|
+
|
|
65
|
+
const LOCAL_LINEAGE_SCOPE = "local";
|
|
66
|
+
|
|
67
|
+
export type CodexWorkflowLane = "worker" | "interactive";
|
|
68
|
+
|
|
69
|
+
interface CodexLineageRecord {
|
|
70
|
+
threadId: string;
|
|
71
|
+
/** This thread's own pool binding key, byte-identical to what `codexPoolAffinityKey` returns. */
|
|
72
|
+
conversationKey: string;
|
|
73
|
+
/** Immediate parent's raw thread id, retained once seen even if a later turn omits the header. */
|
|
74
|
+
parentThreadId?: string;
|
|
75
|
+
/** Topmost ancestor's conversation key: a grandchild resolves to the root's, not its parent's. */
|
|
76
|
+
rootSessionKey: string;
|
|
77
|
+
lastUsedAt: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface CodexLineageScope {
|
|
81
|
+
/** threadId -> record, iterated oldest-first so TTL pruning and eviction stay amortised O(1). */
|
|
82
|
+
records: Map<string, CodexLineageRecord>;
|
|
83
|
+
/** conversationKey -> threadId, so cost attribution is a lookup instead of a scan. */
|
|
84
|
+
threadIdByConversationKey: Map<string, string>;
|
|
85
|
+
/** parentThreadId -> child thread ids, most recent first, capped. */
|
|
86
|
+
childThreadIdsByParent: Map<string, string[]>;
|
|
87
|
+
lastUsedAt: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* What placement and cost attribution are allowed to see. `parentConversationKey` is the parent's
|
|
92
|
+
* OWN binding key -- either recorded, or derived from the shared session when the parent has not
|
|
93
|
+
* been seen yet -- never the raw header value the old affinity key returned.
|
|
94
|
+
*/
|
|
95
|
+
export interface CodexThreadLineage {
|
|
96
|
+
readonly conversationKey: string;
|
|
97
|
+
readonly rootSessionKey: string;
|
|
98
|
+
readonly parentThreadId?: string;
|
|
99
|
+
readonly parentConversationKey?: string;
|
|
100
|
+
/**
|
|
101
|
+
* The key the pre-#4546 rule would have returned for this request -- the RAW parent id -- when
|
|
102
|
+
* that differs from the key it binds under now. Present so routing can adopt a binding left by
|
|
103
|
+
* the old rule exactly once; see the lifetime note at the top of this file.
|
|
104
|
+
*/
|
|
105
|
+
readonly legacyConversationKey?: string;
|
|
106
|
+
/** Siblings under the same declared parent, most recently used first. */
|
|
107
|
+
readonly siblingConversationKeys: readonly string[];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The identity the pool affinity key and the lineage record are both derived from. */
|
|
111
|
+
export interface CodexConversationIdentity {
|
|
112
|
+
readonly conversationKey: string;
|
|
113
|
+
/** Thread id this request records under: its own, or the parent's on a parent-only request. */
|
|
114
|
+
readonly recordThreadId: string;
|
|
115
|
+
readonly sessionId?: string;
|
|
116
|
+
readonly parentThreadId?: string;
|
|
117
|
+
/** Raw parent id, i.e. the key the pre-#4546 rule returned for this request. */
|
|
118
|
+
readonly legacyConversationKey?: string;
|
|
119
|
+
/** True only when the request names a parent distinct from its own thread. */
|
|
120
|
+
readonly declaresParent: boolean;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const lineageByScope = new Map<string, CodexLineageScope>();
|
|
124
|
+
|
|
125
|
+
/** A record is only evidence while it is live; an idle-expired one answers like no record. */
|
|
126
|
+
function liveLineageRecord(
|
|
127
|
+
scope: CodexLineageScope | undefined,
|
|
128
|
+
threadId: string,
|
|
129
|
+
now: number,
|
|
130
|
+
): CodexLineageRecord | undefined {
|
|
131
|
+
const record = scope?.records.get(threadId);
|
|
132
|
+
return record !== undefined && now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
|
|
133
|
+
? record
|
|
134
|
+
: undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function boundedLineageComponent(value: string | null): string | undefined {
|
|
138
|
+
const normalized = value?.trim();
|
|
139
|
+
if (!normalized) return undefined;
|
|
140
|
+
if (retainedUtf8Bytes(normalized) > CODEX_LINEAGE_COMPONENT_MAX_BYTES) return undefined;
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The single derivation behind both the pool affinity key and lineage records. Keeping it here,
|
|
146
|
+
* rather than duplicated at the call site, is what guarantees a record's `conversationKey` is
|
|
147
|
+
* byte-identical to the key the thread actually binds under.
|
|
148
|
+
*/
|
|
149
|
+
export function codexConversationKeyFor(familyId: string, threadId: string): string {
|
|
150
|
+
return `app:${createHmac("sha256", CODEX_LINEAGE_KEY)
|
|
151
|
+
.update("opencodex-app-pool-affinity-v1\0")
|
|
152
|
+
.update(familyId)
|
|
153
|
+
.update("\0")
|
|
154
|
+
.update(threadId)
|
|
155
|
+
.digest("base64url")}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve a request's conversation identity, or undefined when it carries no bindable thread
|
|
160
|
+
* identity at all.
|
|
161
|
+
*
|
|
162
|
+
* The set of requests that produce NO key is deliberately unchanged from the pre-#4546 rule: a
|
|
163
|
+
* bare `thread-id` with neither a session nor a parent stays unbound, exactly as the Desktop
|
|
164
|
+
* fallback required both halves of its pair. Only the VALUE moves, and only for requests that
|
|
165
|
+
* name a parent:
|
|
166
|
+
*
|
|
167
|
+
* - root (`session-id` + `thread-id`) -> HMAC(session, thread), unchanged;
|
|
168
|
+
* - child (parent + own `thread-id`) -> HMAC(session ?? parent, thread), previously the raw parent
|
|
169
|
+
* id, which is what made siblings share one entry and made a child's first turn land on a key
|
|
170
|
+
* the root had never bound;
|
|
171
|
+
* - parent-only (no `thread-id`) -> the parent's OWN recorded key when this scope has one, and
|
|
172
|
+
* otherwise HMAC(session ?? parent, parent).
|
|
173
|
+
*
|
|
174
|
+
* That last case is the one with a trap in it. A parent-only turn belongs to the parent's
|
|
175
|
+
* conversation, so it has to land on the binding the parent is already using -- but the parent's
|
|
176
|
+
* key is HMAC(session, thread), and HMAC(parent, parent) reproduces it only when the session id
|
|
177
|
+
* and the thread id are the same string. Codex's own root happens to satisfy that, which is
|
|
178
|
+
* exactly why deriving the key looks correct until a caller whose session differs from its thread
|
|
179
|
+
* starts a COLD conversation on every parent-only turn and overwrites the parent's record on the
|
|
180
|
+
* way through. So the recorded key wins, the session-derived key is the fallback that reproduces
|
|
181
|
+
* it when the parent has not been seen in this scope, and the raw parent id is never the answer.
|
|
182
|
+
*/
|
|
183
|
+
export function codexConversationIdentity(
|
|
184
|
+
headers: Headers,
|
|
185
|
+
now = Date.now(),
|
|
186
|
+
): CodexConversationIdentity | undefined {
|
|
187
|
+
const threadId = boundedLineageComponent(headers.get("thread-id"));
|
|
188
|
+
const sessionId = boundedLineageComponent(headers.get("session-id"));
|
|
189
|
+
const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
|
|
190
|
+
|
|
191
|
+
if (threadId === undefined) {
|
|
192
|
+
if (parentThreadId === undefined) return undefined;
|
|
193
|
+
const recorded = liveLineageRecord(
|
|
194
|
+
lineageByScope.get(codexLineageScopeKey(headers)),
|
|
195
|
+
parentThreadId,
|
|
196
|
+
now,
|
|
197
|
+
);
|
|
198
|
+
return {
|
|
199
|
+
conversationKey: recorded?.conversationKey
|
|
200
|
+
?? codexConversationKeyFor(sessionId ?? parentThreadId, parentThreadId),
|
|
201
|
+
recordThreadId: parentThreadId,
|
|
202
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
203
|
+
legacyConversationKey: parentThreadId,
|
|
204
|
+
declaresParent: false,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const familyId = sessionId ?? parentThreadId;
|
|
208
|
+
if (familyId === undefined) return undefined;
|
|
209
|
+
return {
|
|
210
|
+
conversationKey: codexConversationKeyFor(familyId, threadId),
|
|
211
|
+
recordThreadId: threadId,
|
|
212
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
213
|
+
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
214
|
+
...(parentThreadId !== undefined ? { legacyConversationKey: parentThreadId } : {}),
|
|
215
|
+
declaresParent: parentThreadId !== undefined && parentThreadId !== threadId,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Which authenticated caller this request's lineage belongs to. The bearer is never stored; an
|
|
221
|
+
* unauthenticated (loopback-trusted) request lands in the single local scope.
|
|
222
|
+
*/
|
|
223
|
+
export function codexLineageScopeKey(headers: Headers): string {
|
|
224
|
+
const authorization = headers.get("authorization")?.trim();
|
|
225
|
+
if (!authorization) return LOCAL_LINEAGE_SCOPE;
|
|
226
|
+
return `auth:${createHmac("sha256", CODEX_LINEAGE_KEY)
|
|
227
|
+
.update("opencodex-lineage-scope-v1\0")
|
|
228
|
+
.update(authorization)
|
|
229
|
+
.digest("base64url")}`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function dropLineageRecord(scope: CodexLineageScope, threadId: string): void {
|
|
233
|
+
const record = scope.records.get(threadId);
|
|
234
|
+
if (record === undefined) return;
|
|
235
|
+
scope.records.delete(threadId);
|
|
236
|
+
if (scope.threadIdByConversationKey.get(record.conversationKey) === threadId) {
|
|
237
|
+
scope.threadIdByConversationKey.delete(record.conversationKey);
|
|
238
|
+
}
|
|
239
|
+
if (record.parentThreadId === undefined) return;
|
|
240
|
+
const siblings = scope.childThreadIdsByParent.get(record.parentThreadId);
|
|
241
|
+
if (siblings === undefined) return;
|
|
242
|
+
const remaining = siblings.filter(id => id !== threadId);
|
|
243
|
+
if (remaining.length === 0) scope.childThreadIdsByParent.delete(record.parentThreadId);
|
|
244
|
+
else scope.childThreadIdsByParent.set(record.parentThreadId, remaining);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Records are held in least-recently-used order, so the expired ones are a prefix. */
|
|
248
|
+
function pruneLineageScope(scope: CodexLineageScope, now: number): void {
|
|
249
|
+
for (const [threadId, record] of scope.records) {
|
|
250
|
+
if (now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
|
|
251
|
+
dropLineageRecord(scope, threadId);
|
|
252
|
+
}
|
|
253
|
+
while (scope.records.size > CODEX_LINEAGE_MAX_ENTRIES) {
|
|
254
|
+
const oldest = scope.records.keys().next();
|
|
255
|
+
if (oldest.done === true) break;
|
|
256
|
+
dropLineageRecord(scope, oldest.value);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function pruneLineageScopes(now: number): void {
|
|
261
|
+
for (const [scopeKey, scope] of lineageByScope) {
|
|
262
|
+
if (now - scope.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS) break;
|
|
263
|
+
lineageByScope.delete(scopeKey);
|
|
264
|
+
}
|
|
265
|
+
while (lineageByScope.size > CODEX_LINEAGE_MAX_SCOPES) {
|
|
266
|
+
const oldest = lineageByScope.keys().next();
|
|
267
|
+
if (oldest.done === true) break;
|
|
268
|
+
lineageByScope.delete(oldest.value);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function touchLineageScope(scopeKey: string, now: number): CodexLineageScope {
|
|
273
|
+
const existing = lineageByScope.get(scopeKey);
|
|
274
|
+
const scope: CodexLineageScope = existing ?? {
|
|
275
|
+
records: new Map<string, CodexLineageRecord>(),
|
|
276
|
+
threadIdByConversationKey: new Map<string, string>(),
|
|
277
|
+
childThreadIdsByParent: new Map<string, string[]>(),
|
|
278
|
+
lastUsedAt: now,
|
|
279
|
+
};
|
|
280
|
+
if (existing !== undefined) lineageByScope.delete(scopeKey);
|
|
281
|
+
scope.lastUsedAt = now;
|
|
282
|
+
lineageByScope.set(scopeKey, scope);
|
|
283
|
+
pruneLineageScopes(now);
|
|
284
|
+
pruneLineageScope(scope, now);
|
|
285
|
+
return scope;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The lineage view for one identity inside one scope, computed without writing anything.
|
|
290
|
+
*
|
|
291
|
+
* A parent seen for the first time through one of its children is derived rather than invented:
|
|
292
|
+
* the child knows the shared session, so HMAC(session, parent) reproduces the key the parent
|
|
293
|
+
* binds under. Once the parent has actually been recorded, its own key wins.
|
|
294
|
+
*
|
|
295
|
+
* Depth is transitive by construction -- a grandchild inherits its parent's resolved root instead
|
|
296
|
+
* of re-deriving one hop -- so a workflow never scatters across several roots.
|
|
297
|
+
*/
|
|
298
|
+
function lineageFor(
|
|
299
|
+
scope: CodexLineageScope | undefined,
|
|
300
|
+
identity: CodexConversationIdentity,
|
|
301
|
+
now: number,
|
|
302
|
+
): CodexThreadLineage {
|
|
303
|
+
const previous = liveLineageRecord(scope, identity.recordThreadId, now);
|
|
304
|
+
// A turn that omits the parent header does not orphan a thread whose parent is already known.
|
|
305
|
+
// That retention is the whole of the lineage-backed worker answer below.
|
|
306
|
+
const parentThreadId = identity.declaresParent
|
|
307
|
+
? identity.parentThreadId
|
|
308
|
+
: previous?.parentThreadId;
|
|
309
|
+
|
|
310
|
+
const parentRecord = parentThreadId !== undefined
|
|
311
|
+
? liveLineageRecord(scope, parentThreadId, now)
|
|
312
|
+
: undefined;
|
|
313
|
+
const parentConversationKey = parentThreadId === undefined
|
|
314
|
+
? undefined
|
|
315
|
+
: parentRecord?.conversationKey
|
|
316
|
+
?? codexConversationKeyFor(identity.sessionId ?? parentThreadId, parentThreadId);
|
|
317
|
+
const rootSessionKey = parentConversationKey === undefined
|
|
318
|
+
? identity.conversationKey
|
|
319
|
+
: parentRecord?.rootSessionKey ?? parentConversationKey;
|
|
320
|
+
|
|
321
|
+
const siblingConversationKeys: string[] = [];
|
|
322
|
+
if (parentThreadId !== undefined && scope !== undefined) {
|
|
323
|
+
for (const siblingThreadId of scope.childThreadIdsByParent.get(parentThreadId) ?? []) {
|
|
324
|
+
if (siblingThreadId === identity.recordThreadId) continue;
|
|
325
|
+
const sibling = liveLineageRecord(scope, siblingThreadId, now);
|
|
326
|
+
if (sibling !== undefined) siblingConversationKeys.push(sibling.conversationKey);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Only a key the old rule would have produced AND that this request no longer uses is a
|
|
331
|
+
// migration candidate. A root's key is unchanged, so it never carries one.
|
|
332
|
+
const legacyConversationKey = identity.legacyConversationKey !== undefined
|
|
333
|
+
&& identity.legacyConversationKey !== identity.conversationKey
|
|
334
|
+
? identity.legacyConversationKey
|
|
335
|
+
: undefined;
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
conversationKey: identity.conversationKey,
|
|
339
|
+
rootSessionKey,
|
|
340
|
+
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
341
|
+
...(parentConversationKey !== undefined ? { parentConversationKey } : {}),
|
|
342
|
+
...(legacyConversationKey !== undefined ? { legacyConversationKey } : {}),
|
|
343
|
+
siblingConversationKeys,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Read this request's lineage without recording it.
|
|
349
|
+
*
|
|
350
|
+
* A preview must see what the final resolution will see, but it must not be the thing that
|
|
351
|
+
* creates the record: preview runs before auth has decided whether this request may hold Pool
|
|
352
|
+
* state at all, and a record written there would outlive a decision to hold none.
|
|
353
|
+
*/
|
|
354
|
+
export function resolveCodexThreadLineage(
|
|
355
|
+
headers: Headers,
|
|
356
|
+
now = Date.now(),
|
|
357
|
+
): CodexThreadLineage | undefined {
|
|
358
|
+
const identity = codexConversationIdentity(headers, now);
|
|
359
|
+
if (identity === undefined) return undefined;
|
|
360
|
+
return lineageFor(lineageByScope.get(codexLineageScopeKey(headers)), identity, now);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Record this request's thread relation and return the resolved lineage. */
|
|
364
|
+
export function recordCodexThreadLineage(
|
|
365
|
+
headers: Headers,
|
|
366
|
+
now = Date.now(),
|
|
367
|
+
): CodexThreadLineage | undefined {
|
|
368
|
+
const identity = codexConversationIdentity(headers, now);
|
|
369
|
+
if (identity === undefined) return undefined;
|
|
370
|
+
const scope = touchLineageScope(codexLineageScopeKey(headers), now);
|
|
371
|
+
const lineage = lineageFor(scope, identity, now);
|
|
372
|
+
const parentThreadId = lineage.parentThreadId;
|
|
373
|
+
|
|
374
|
+
// Re-insert rather than mutate: the records map doubles as the LRU order.
|
|
375
|
+
dropLineageRecord(scope, identity.recordThreadId);
|
|
376
|
+
scope.records.set(identity.recordThreadId, {
|
|
377
|
+
threadId: identity.recordThreadId,
|
|
378
|
+
conversationKey: identity.conversationKey,
|
|
379
|
+
...(parentThreadId !== undefined ? { parentThreadId } : {}),
|
|
380
|
+
rootSessionKey: lineage.rootSessionKey,
|
|
381
|
+
lastUsedAt: now,
|
|
382
|
+
});
|
|
383
|
+
scope.threadIdByConversationKey.set(identity.conversationKey, identity.recordThreadId);
|
|
384
|
+
if (parentThreadId !== undefined) {
|
|
385
|
+
const siblings = (scope.childThreadIdsByParent.get(parentThreadId) ?? [])
|
|
386
|
+
.filter(id => id !== identity.recordThreadId);
|
|
387
|
+
siblings.unshift(identity.recordThreadId);
|
|
388
|
+
scope.childThreadIdsByParent.set(parentThreadId, siblings.slice(0, CODEX_LINEAGE_MAX_SIBLINGS));
|
|
389
|
+
}
|
|
390
|
+
pruneLineageScope(scope, now);
|
|
391
|
+
|
|
392
|
+
return lineage;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Cost-attribution lookup for other layers: which root workflow owns this conversation key.
|
|
397
|
+
* Scoped like the records, so a caller can only ever resolve inside its own scope, and read-only
|
|
398
|
+
* -- reading a lineage for accounting must not extend its lifetime.
|
|
399
|
+
*/
|
|
400
|
+
export function codexThreadLineageLookup(
|
|
401
|
+
conversationKey: string,
|
|
402
|
+
scopeKey: string,
|
|
403
|
+
now = Date.now(),
|
|
404
|
+
): { conversationKey: string; rootSessionKey: string; parentThreadId?: string } | undefined {
|
|
405
|
+
const scope = lineageByScope.get(scopeKey);
|
|
406
|
+
if (scope === undefined) return undefined;
|
|
407
|
+
const threadId = scope.threadIdByConversationKey.get(conversationKey);
|
|
408
|
+
if (threadId === undefined) return undefined;
|
|
409
|
+
const record = scope.records.get(threadId);
|
|
410
|
+
if (record === undefined || now - record.lastUsedAt > CODEX_LINEAGE_IDLE_TTL_MS) return undefined;
|
|
411
|
+
return {
|
|
412
|
+
conversationKey: record.conversationKey,
|
|
413
|
+
rootSessionKey: record.rootSessionKey,
|
|
414
|
+
...(record.parentThreadId !== undefined ? { parentThreadId: record.parentThreadId } : {}),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The root a request's spend belongs to, for a caller holding headers rather than a key. An
|
|
420
|
+
* unrecorded conversation is its own root, so this never answers null for a bindable request and
|
|
421
|
+
* an accounting layer has no reason to invent one.
|
|
422
|
+
*/
|
|
423
|
+
export function codexLineageRootForRequest(headers: Headers, now = Date.now()): string | undefined {
|
|
424
|
+
const identity = codexConversationIdentity(headers, now);
|
|
425
|
+
if (identity === undefined) return undefined;
|
|
426
|
+
return codexThreadLineageLookup(identity.conversationKey, codexLineageScopeKey(headers), now)
|
|
427
|
+
?.rootSessionKey
|
|
428
|
+
?? identity.conversationKey;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The lineage-backed worker/interactive answer.
|
|
433
|
+
*
|
|
434
|
+
* Admission classifies header-only today: a request is worker traffic only when it names a parent
|
|
435
|
+
* AND a distinct `thread-id`, so a fan-out turn that stopped sending the parent header reads as
|
|
436
|
+
* interactive. This keeps that rule and adds what the headers could not say -- a thread already
|
|
437
|
+
* recorded with a parent is worker traffic. Nothing here changes admission; a later lane consumes
|
|
438
|
+
* it.
|
|
439
|
+
*/
|
|
440
|
+
export function codexLineageWorkflowLane(headers: Headers, now = Date.now()): CodexWorkflowLane {
|
|
441
|
+
const threadId = boundedLineageComponent(headers.get("thread-id"));
|
|
442
|
+
const parentThreadId = boundedLineageComponent(headers.get("x-codex-parent-thread-id"));
|
|
443
|
+
if (parentThreadId !== undefined && threadId !== undefined && threadId !== parentThreadId) {
|
|
444
|
+
return "worker";
|
|
445
|
+
}
|
|
446
|
+
if (threadId === undefined) return "interactive";
|
|
447
|
+
const record = lineageByScope.get(codexLineageScopeKey(headers))?.records.get(threadId);
|
|
448
|
+
return record !== undefined
|
|
449
|
+
&& now - record.lastUsedAt <= CODEX_LINEAGE_IDLE_TTL_MS
|
|
450
|
+
&& record.parentThreadId !== undefined
|
|
451
|
+
? "worker"
|
|
452
|
+
: "interactive";
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Test-only reset; production state is process-local and dies with the process. */
|
|
456
|
+
export function clearCodexThreadLineageForTests(): void {
|
|
457
|
+
lineageByScope.clear();
|
|
458
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
3
|
-
import type { OcxConfig } from "../types";
|
|
3
|
+
import type { CodexAccountCredentialRecord, OcxConfig } from "../types";
|
|
4
4
|
import { isSelectableCodexPoolAccount } from "./account-id";
|
|
5
|
-
import { getValidCodexToken,
|
|
5
|
+
import { getValidCodexToken, loadCodexAccountRecordSnapshot } from "./account-store";
|
|
6
6
|
import {
|
|
7
7
|
getMainAccountToken,
|
|
8
8
|
getValidMainAccountToken,
|
|
9
9
|
MAIN_CODEX_ACCOUNT_ID,
|
|
10
10
|
type NativeMainRefreshDependencies,
|
|
11
11
|
} from "./main-account";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
ACCOUNT_GATED_NATIVE_OPENAI_MODELS,
|
|
14
|
+
NATIVE_GPT6_ASTRA_MODEL,
|
|
15
|
+
} from "./catalog/native-models";
|
|
13
16
|
import { loadPersistedCodexRuntime } from "./runtime";
|
|
14
17
|
import { codexRuntimeStateEpoch } from "./runtime";
|
|
15
18
|
import upstreamModelsSnapshot from "./data/upstream-models.json";
|
|
@@ -520,17 +523,48 @@ function boundedCacheSet(accountId: string, value: CachedAccountModels): void {
|
|
|
520
523
|
evictClass(accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX));
|
|
521
524
|
}
|
|
522
525
|
|
|
526
|
+
/**
|
|
527
|
+
* An identity resolver scoped to one caller's pass, reading each backing store at most once.
|
|
528
|
+
*
|
|
529
|
+
* The identity check itself is unchanged -- same prefix rule, same tombstone and missing-credential
|
|
530
|
+
* rejection, same `pool:<generation>:<chatgptAccountId>` shape -- but the READ is hoisted. Per-id
|
|
531
|
+
* resolution reloads and reparses the whole `codex-accounts.json` every call, so a loop over cache
|
|
532
|
+
* entries paid one full-store read per entry: the denial reader admits 64 accounts with four client
|
|
533
|
+
* versions each, which is up to 256 synchronous reads to score a single warm flagship request.
|
|
534
|
+
*
|
|
535
|
+
* Both stores are read lazily, so a pass that touches only Direct callers, or only native main,
|
|
536
|
+
* still opens nothing it does not need. Neither backing read is memoized across passes: a resolver
|
|
537
|
+
* lives for one synchronous loop, and that loop has no suspension point, so nothing this process
|
|
538
|
+
* does can change the file underneath it. A snapshot is therefore not staler than per-entry reads
|
|
539
|
+
* would have been -- it is strictly more coherent, because a foreign writer landing mid-loop can no
|
|
540
|
+
* longer give the earlier entries one generation and the later ones another.
|
|
541
|
+
*/
|
|
542
|
+
function credentialIdentityResolver(): (accountId: string) => string | undefined {
|
|
543
|
+
let records: Readonly<Record<string, CodexAccountCredentialRecord>> | undefined;
|
|
544
|
+
let mainRead = false;
|
|
545
|
+
let mainIdentity: string | undefined;
|
|
546
|
+
return (accountId: string): string | undefined => {
|
|
547
|
+
if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
|
|
548
|
+
return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
|
|
549
|
+
}
|
|
550
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
551
|
+
if (!mainRead) {
|
|
552
|
+
const token = getMainAccountToken();
|
|
553
|
+
mainIdentity = token ? `main:${token.chatgptAccountId}` : undefined;
|
|
554
|
+
mainRead = true;
|
|
555
|
+
}
|
|
556
|
+
return mainIdentity;
|
|
557
|
+
}
|
|
558
|
+
records ??= loadCodexAccountRecordSnapshot();
|
|
559
|
+
const record = records[accountId];
|
|
560
|
+
if (!record?.credential || record.deletedAt != null) return undefined;
|
|
561
|
+
return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Single-id resolution. Identical to one call through a fresh {@link credentialIdentityResolver}. */
|
|
523
566
|
function currentCredentialIdentity(accountId: string): string | undefined {
|
|
524
|
-
|
|
525
|
-
return `direct:${accountId.slice(DIRECT_CALLER_ACCOUNT_PREFIX.length)}`;
|
|
526
|
-
}
|
|
527
|
-
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
528
|
-
const token = getMainAccountToken();
|
|
529
|
-
return token ? `main:${token.chatgptAccountId}` : undefined;
|
|
530
|
-
}
|
|
531
|
-
const record = readCodexAccountRecord(accountId);
|
|
532
|
-
if (!record?.credential || record.deletedAt != null) return undefined;
|
|
533
|
-
return `pool:${record.generation}:${record.credential.chatgptAccountId}`;
|
|
567
|
+
return credentialIdentityResolver()(accountId);
|
|
534
568
|
}
|
|
535
569
|
|
|
536
570
|
async function accountCredentialSnapshot(
|
|
@@ -926,8 +960,10 @@ export async function ensureCodexEntitlementFreshness(
|
|
|
926
960
|
);
|
|
927
961
|
const candidates = normalizedCandidateAccountIds(config);
|
|
928
962
|
const mutationEpoch = codexCredentialMutationEpoch();
|
|
963
|
+
// Same hoist as the denial pass: this prologue is synchronous and reads once per candidate.
|
|
964
|
+
const identityOf = credentialIdentityResolver();
|
|
929
965
|
const identityEntries = candidates.map(accountId => (
|
|
930
|
-
[accountId,
|
|
966
|
+
[accountId, identityOf(accountId) ?? null] as const
|
|
931
967
|
));
|
|
932
968
|
const identityVector = new Map(identityEntries);
|
|
933
969
|
const workset = candidates.filter(accountId => needsEntitlementRefresh(
|
|
@@ -980,8 +1016,9 @@ export function getCodexModelEntitlementStatus(
|
|
|
980
1016
|
clientVersion?: string | null,
|
|
981
1017
|
): CodexModelEntitlementStatus {
|
|
982
1018
|
const version = resolveCodexEntitlementClientVersion(clientVersion);
|
|
1019
|
+
const identityOf = credentialIdentityResolver();
|
|
983
1020
|
const accounts = candidateAccountIds(config).flatMap(accountId => {
|
|
984
|
-
const credentialIdentity =
|
|
1021
|
+
const credentialIdentity = identityOf(accountId);
|
|
985
1022
|
return credentialIdentity ? [{ accountId, credentialIdentity }] : [];
|
|
986
1023
|
});
|
|
987
1024
|
if (accounts.length === 0) return { status: "unavailable" };
|
|
@@ -1159,6 +1196,101 @@ export function availableAccountGatedNativeModels(
|
|
|
1159
1196
|
)));
|
|
1160
1197
|
}
|
|
1161
1198
|
|
|
1199
|
+
/**
|
|
1200
|
+
* Native models that stay unconditionally VISIBLE while their per-account availability still
|
|
1201
|
+
* varies.
|
|
1202
|
+
*
|
|
1203
|
+
* This is deliberately not `ACCOUNT_GATED_NATIVE_OPENAI_MODELS` and must never become it. That set
|
|
1204
|
+
* fails closed on ABSENCE of evidence: membership hides the row from the catalog and refuses the
|
|
1205
|
+
* request before dispatch, which is exactly what the owner decision of 2026-09-04 removed the
|
|
1206
|
+
* flagships from. A timed-out fetch or a shard that has not caught up would make the model vanish
|
|
1207
|
+
* from the picker, and "opencodex lost my model" is a worse failure than one upstream 400.
|
|
1208
|
+
*
|
|
1209
|
+
* This set carries the opposite polarity. It admits only a CONFIRMED DENIAL as evidence, and it
|
|
1210
|
+
* feeds an ordering preference rather than a refusal, so absent or stale evidence changes nothing.
|
|
1211
|
+
* That is the distinction #4768 asked for: a pool holding a Plus account and a Free account should
|
|
1212
|
+
* stop handing Sol/Astra to the Free account whose own authenticated roster already says it cannot
|
|
1213
|
+
* serve them, without gating the model on evidence that may never arrive.
|
|
1214
|
+
*/
|
|
1215
|
+
export const ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
|
|
1216
|
+
"gpt-5.6-sol",
|
|
1217
|
+
"gpt-5.6-terra",
|
|
1218
|
+
"gpt-5.6-luna",
|
|
1219
|
+
NATIVE_GPT6_ASTRA_MODEL,
|
|
1220
|
+
]);
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Accounts whose OWN authenticated roster definitively omits `modelId`, read synchronously from
|
|
1224
|
+
* evidence discovery has already gathered.
|
|
1225
|
+
*
|
|
1226
|
+
* Synchronous and cache-only by contract. The gated path may await `resolveCodexModelEntitlements`
|
|
1227
|
+
* because a gated model is rare and already pays a bounded discovery call; the flagships are the
|
|
1228
|
+
* most commonly requested models in the product, and putting an authenticated upstream fetch per
|
|
1229
|
+
* account on that request path would trade one occasional 400 for latency on every turn. The cache
|
|
1230
|
+
* this reads is warmed anyway: `modelsForCredential` stores each account's FULL roster, and
|
|
1231
|
+
* background catalog sync (`src/codex/catalog/retained-sync.ts`) and convergence already resolve
|
|
1232
|
+
* entitlements for every pool account.
|
|
1233
|
+
*
|
|
1234
|
+
* Returns `undefined` rather than an empty set when nothing is denied, so a caller cannot confuse
|
|
1235
|
+
* "no account is denied" with "no evidence exists" — both mean the same thing here, which is that
|
|
1236
|
+
* selection must be left exactly as it was.
|
|
1237
|
+
*
|
|
1238
|
+
* Only `denied` counts. `unknown` covers an unconfirmed account, a roster fetched under a client
|
|
1239
|
+
* version too old to return the model, and an expired or credential-stale entry; none of those is
|
|
1240
|
+
* proof that the account lacks the model, and treating them as proof is how 2.36.0 removed
|
|
1241
|
+
* sol/terra/luna from accounts that owned them (#3022).
|
|
1242
|
+
*/
|
|
1243
|
+
export function cachedDeniedCodexAccountIdsForModel(
|
|
1244
|
+
modelId: string | undefined,
|
|
1245
|
+
now = Date.now(),
|
|
1246
|
+
options: { excludeAccountIds?: ReadonlySet<string> } = {},
|
|
1247
|
+
): ReadonlySet<string> | undefined {
|
|
1248
|
+
if (!modelId || !ENTITLEMENT_PREFERRED_NATIVE_OPENAI_MODELS.has(modelId)) return undefined;
|
|
1249
|
+
const denied = new Set<string>();
|
|
1250
|
+
const granted = new Set<string>();
|
|
1251
|
+
// One resolver for the whole pass: the loop below runs once per cached (account, client version)
|
|
1252
|
+
// entry, and resolving an identity per entry meant a full account-store read per entry.
|
|
1253
|
+
const identityOf = credentialIdentityResolver();
|
|
1254
|
+
for (const [key, entry] of accountModelsCache) {
|
|
1255
|
+
const accountId = accountIdOfCacheKey(key);
|
|
1256
|
+
// A forwarded Direct credential is one request's caller, never a pool candidate.
|
|
1257
|
+
if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) continue;
|
|
1258
|
+
// The caller's read fence, honoured BEFORE `identityOf` below, because that is the read: for
|
|
1259
|
+
// native main it resolves the physical stored token. A request that is forbidden to read main
|
|
1260
|
+
// -- a profile switch draining it, or a request-owned credential that owns no main state --
|
|
1261
|
+
// must not reread account storage just to score an ordering preference. Dropping the account
|
|
1262
|
+
// leaves it UNKNOWN rather than denied, which is the same outcome as having no cached roster
|
|
1263
|
+
// for it and changes no selection. The resolver reads lazily for the same reason: an excluded
|
|
1264
|
+
// account `continue`s here, so its store is never opened at all.
|
|
1265
|
+
if (options.excludeAccountIds?.has(accountId)) continue;
|
|
1266
|
+
if (entry.expiresAt <= now) continue;
|
|
1267
|
+
// A credential we can currently read AND that differs is proof the entry answers for a
|
|
1268
|
+
// different account than this id now names, so its denial is not evidence about the current
|
|
1269
|
+
// one. An UNREADABLE credential is not proof of anything, and the same unknown-is-not-denied
|
|
1270
|
+
// discipline that governs rosters governs identities: it leaves the entry in place rather
|
|
1271
|
+
// than manufacturing a reason to ignore it.
|
|
1272
|
+
const identity = identityOf(accountId);
|
|
1273
|
+
if (identity !== undefined && identity !== entry.credentialIdentity) continue;
|
|
1274
|
+
const state = codexModelEntitlementStateForRoster(
|
|
1275
|
+
entry.models,
|
|
1276
|
+
entry.confirmed,
|
|
1277
|
+
entry.clientVersion,
|
|
1278
|
+
modelId,
|
|
1279
|
+
);
|
|
1280
|
+
if (state === "granted") granted.add(accountId);
|
|
1281
|
+
else if (state === "denied") denied.add(accountId);
|
|
1282
|
+
}
|
|
1283
|
+
// One account holds one entry per client version, and upstream filters the roster by that
|
|
1284
|
+
// version. So the same account can legitimately carry a granted entry under a current client
|
|
1285
|
+
// and a denied one under an older client that predates the model. Positive evidence is
|
|
1286
|
+
// authoritative regardless of which version asked for it -- the same rule
|
|
1287
|
+
// `codexModelEntitlementStateForRoster` applies within a single entry -- so a grant anywhere
|
|
1288
|
+
// clears the denial rather than being outvoted by whichever entry the map happened to yield
|
|
1289
|
+
// last.
|
|
1290
|
+
for (const accountId of granted) denied.delete(accountId);
|
|
1291
|
+
return denied.size > 0 ? denied : undefined;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1162
1294
|
/** Synchronous projection for management/catalog readers after a discovery pass. */
|
|
1163
1295
|
export function cachedAvailableAccountGatedNativeModels(
|
|
1164
1296
|
now = Date.now(),
|
|
@@ -1192,6 +1324,11 @@ export function cachedAvailableAccountGatedNativeModels(
|
|
|
1192
1324
|
|
|
1193
1325
|
export function isCodexModelEntitlementSnapshotCurrent(snapshot: CodexModelEntitlementSnapshot): boolean {
|
|
1194
1326
|
for (const [accountId, identity] of snapshot.credentialIdentities) {
|
|
1327
|
+
// Deliberately per-id, unlike the passes above. This is a fail-closed publication gate asking
|
|
1328
|
+
// whether a snapshot is STILL current, so the freshest possible answer per account is the
|
|
1329
|
+
// point of the read. A pass-wide snapshot would be a coherence win everywhere else and a
|
|
1330
|
+
// small weakening here: it could answer "current" for a later account from a record a
|
|
1331
|
+
// concurrent reauth had already replaced.
|
|
1195
1332
|
if (currentCredentialIdentity(accountId) !== identity) return false;
|
|
1196
1333
|
}
|
|
1197
1334
|
return true;
|