@bitkyc08/opencodex 2.55.0 → 2.56.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-VuoiWj9J.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- 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-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- 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 +611 -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 +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- 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 +554 -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 +422 -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 +95 -28
- 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 +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -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 +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- 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/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/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 +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- 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 +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -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/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 +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -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 +335 -0
- package/src/server/index.ts +40 -2547
- 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/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -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 +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../account-store";
|
|
2
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../main-account";
|
|
3
|
+
import { retainedUtf8Bytes } from "../../lib/admission";
|
|
4
|
+
import { clearAllCodexPoolRefreshFailures } from "../pool-refresh-backoff";
|
|
5
|
+
import type { CodexThreadLineage } from "../lineage";
|
|
6
|
+
import type { CodexQuotaScope } from "./health-store";
|
|
7
|
+
|
|
8
|
+
export type ThreadAffinityEntry = {
|
|
9
|
+
accountId: string;
|
|
10
|
+
generation: number;
|
|
11
|
+
createdAt: number;
|
|
12
|
+
lastUsedAt: number;
|
|
13
|
+
// Last time the bound account's quota threshold was re-evaluated for this
|
|
14
|
+
// thread (interval-gated to avoid per-request flapping). See REEVAL_INTERVAL_MS.
|
|
15
|
+
lastReevalAt: number;
|
|
16
|
+
// When a transient failure streak first forced this thread onto another account
|
|
17
|
+
// while the binding was HELD (#4546). Cleared the moment the bound account serves
|
|
18
|
+
// again; once it ages past CODEX_TRANSIENT_AFFINITY_HOLD_MS the binding is
|
|
19
|
+
// released through the ordinary path instead of detouring forever.
|
|
20
|
+
transientHoldSince?: number;
|
|
21
|
+
// Which account is serving this thread while its own is held under a transient hold.
|
|
22
|
+
// Remembered rather than re-picked per request: under round-robin a fresh pick each turn
|
|
23
|
+
// would walk the ring and start cold on every hop, which is the behaviour the hold exists
|
|
24
|
+
// to prevent. Cleared with transientHoldSince when the bound account serves again.
|
|
25
|
+
transientDetourAccountId?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CodexThreadResolution =
|
|
29
|
+
| { status: "selected"; accountId: string; affinity?: CodexAffinityDecision }
|
|
30
|
+
| { status: "none"; affinity?: CodexAffinityDecision }
|
|
31
|
+
| { status: "expired"; accountId: string; affinity?: CodexAffinityDecision };
|
|
32
|
+
|
|
33
|
+
/** What happened to this thread's binding on this request (#4546). */
|
|
34
|
+
export type CodexAffinityMove =
|
|
35
|
+
/** Served by its own bound account, which was healthy. */
|
|
36
|
+
| "reused"
|
|
37
|
+
/** Served by its own bound account while something transient was wrong with it. */
|
|
38
|
+
| "held"
|
|
39
|
+
/** Served by another account while the binding stayed put. */
|
|
40
|
+
| "detour"
|
|
41
|
+
/** The binding was released and a different account took the thread. */
|
|
42
|
+
| "rebound"
|
|
43
|
+
/** There was no live binding; this request established one. */
|
|
44
|
+
| "new_bind"
|
|
45
|
+
/** The binding was released without a replacement on this request. */
|
|
46
|
+
| "cleared";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Why. A move is the expensive event -- it discards the prompt-cache prefix warmed on the old
|
|
50
|
+
* account -- so the operator should not have to infer it from account labels across log lines,
|
|
51
|
+
* which is how #4546 had to be diagnosed.
|
|
52
|
+
*/
|
|
53
|
+
export type CodexAffinityReason =
|
|
54
|
+
| "healthy"
|
|
55
|
+
| "quota_headroom"
|
|
56
|
+
| "quota_refusal"
|
|
57
|
+
| "transient"
|
|
58
|
+
| "transient_hold_expired"
|
|
59
|
+
| "unusable"
|
|
60
|
+
| "paused"
|
|
61
|
+
| "plan_excluded"
|
|
62
|
+
| "cooldown"
|
|
63
|
+
| "quota_avoided"
|
|
64
|
+
| "generation"
|
|
65
|
+
| "expired"
|
|
66
|
+
| "model_lane"
|
|
67
|
+
/** First placement followed the parent's CURRENT serving account (#4546, wp8). */
|
|
68
|
+
| "lineage_parent"
|
|
69
|
+
/** First placement followed a compatible sibling's current serving account. */
|
|
70
|
+
| "lineage_sibling";
|
|
71
|
+
|
|
72
|
+
export interface CodexAffinityDecision {
|
|
73
|
+
move: CodexAffinityMove;
|
|
74
|
+
reason: CodexAffinityReason;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The decision to report once a binding has been released and selection starts over. */
|
|
78
|
+
export function affinityAfterRelease(
|
|
79
|
+
threadId: string | null,
|
|
80
|
+
releaseReason: CodexAffinityReason | undefined,
|
|
81
|
+
): CodexAffinityDecision {
|
|
82
|
+
// Reported now, so it must not be reported again by the next request.
|
|
83
|
+
clearPendingReleaseReason(threadId);
|
|
84
|
+
return releaseReason === undefined
|
|
85
|
+
? { move: "new_bind", reason: "healthy" }
|
|
86
|
+
: { move: "rebound", reason: releaseReason };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What to report when selection produced no account at all. The binding is gone and nothing took
|
|
91
|
+
* it, which is a `cleared`, and the pending reason is deliberately NOT consumed: a no-account
|
|
92
|
+
* result reaches no auth context and therefore no usage entry, so the next resolve that does
|
|
93
|
+
* produce one is the first place this release can actually be seen.
|
|
94
|
+
*/
|
|
95
|
+
export function affinityOnNoAccount(
|
|
96
|
+
threadId: string | null,
|
|
97
|
+
releaseReason: CodexAffinityReason | undefined,
|
|
98
|
+
): CodexAffinityDecision | undefined {
|
|
99
|
+
if (releaseReason === undefined) return undefined;
|
|
100
|
+
// Hand it forward as well as reporting it. A reason derived from the entry this request just
|
|
101
|
+
// released lives only in a local, so without this the next resolve finds no entry and no
|
|
102
|
+
// pending reason and calls the rebind a fresh healthy bind.
|
|
103
|
+
notePendingReleaseReason(threadId, releaseReason);
|
|
104
|
+
return { move: "cleared", reason: releaseReason };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export const CODEX_THREAD_AFFINITY_IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
108
|
+
export const CODEX_THREAD_AFFINITY_MAX_ENTRIES = 2048;
|
|
109
|
+
const MAX_AFFINITY_COMPONENT_BYTES = 512;
|
|
110
|
+
// Min interval between quota threshold re-evaluations for a single bound thread.
|
|
111
|
+
// Well under the 5h/weekly quota windows, but enough to stop per-request flapping.
|
|
112
|
+
export const CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS = 60_000;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* How long a live binding outlives a TRANSIENT failure streak on its own account (#4546).
|
|
116
|
+
*
|
|
117
|
+
* Being unable to send right now is not the same as losing ownership of the conversation.
|
|
118
|
+
* A 5xx streak is frequently provider-wide rather than account-specific, and deleting the
|
|
119
|
+
* binding for it discards a prompt-cache prefix that the next turn then pays for again --
|
|
120
|
+
* the same cost the quota threshold used to impose, arriving through a different door.
|
|
121
|
+
* So the request detours to another account while the binding is held here.
|
|
122
|
+
*
|
|
123
|
+
* Bounded, because an unbounded hold is its own defect: an account that never recovers
|
|
124
|
+
* would keep a thread detouring indefinitely while the conversation's real warm prefix
|
|
125
|
+
* accumulates somewhere else. Ten minutes is longer than the whole soft-avoid escalation
|
|
126
|
+
* ladder up to its final step, so an ordinary outage resolves inside the hold and a
|
|
127
|
+
* genuine one converts to a real rebind instead of a permanent detour.
|
|
128
|
+
*/
|
|
129
|
+
export const CODEX_TRANSIENT_AFFINITY_HOLD_MS = 10 * 60_000;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Requests without a resolved native model retain the historic one-account-per-
|
|
133
|
+
* thread behavior. Requests with a known quota scope get an independent
|
|
134
|
+
* affinity so a Reserve failover cannot displace the same thread's Terra/Luna
|
|
135
|
+
* account (and vice versa).
|
|
136
|
+
*/
|
|
137
|
+
type BaseThreadAffinityScope = CodexQuotaScope | "legacy";
|
|
138
|
+
type ModelDetourAffinityScope = `model-detour:${BaseThreadAffinityScope}:${string}`;
|
|
139
|
+
type ThreadAffinityScope = BaseThreadAffinityScope | ModelDetourAffinityScope;
|
|
140
|
+
|
|
141
|
+
function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelDetourAffinityScope {
|
|
142
|
+
return scope.startsWith("model-detour:");
|
|
143
|
+
}
|
|
144
|
+
const LEGACY_THREAD_AFFINITY_SCOPE = "legacy" as const;
|
|
145
|
+
const threadAccountMap = new Map<string, Map<ThreadAffinityScope, ThreadAffinityEntry>>();
|
|
146
|
+
let threadAffinityEntryTotal = 0;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Which pool account minted the conversation's carried OpenAI state
|
|
150
|
+
* (`previous_response_id`, encrypted reasoning, provider conversation/file ids).
|
|
151
|
+
* Keyed by the same affinity key as {@link threadAccountMap}, bounded the same
|
|
152
|
+
* way, and process-local — raw account ids never reach a log.
|
|
153
|
+
*/
|
|
154
|
+
type ConversationStateIssuerEntry = {
|
|
155
|
+
accountId: string;
|
|
156
|
+
lastUsedAt: number;
|
|
157
|
+
};
|
|
158
|
+
const conversationStateIssuerMap = new Map<string, ConversationStateIssuerEntry>();
|
|
159
|
+
|
|
160
|
+
export function clearThreadAccountMap(): void {
|
|
161
|
+
threadAccountMap.clear();
|
|
162
|
+
threadAffinityEntryTotal = 0;
|
|
163
|
+
// A refresh cooldown is per-account runtime state learned alongside these bindings. Leaving it
|
|
164
|
+
// behind here keeps an account out of selection after the roster it belonged to is gone.
|
|
165
|
+
clearAllCodexPoolRefreshFailures();
|
|
166
|
+
conversationStateIssuerMap.clear();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function clearConversationStateIssuerMap(): void {
|
|
170
|
+
conversationStateIssuerMap.clear();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function clearThreadAccountMapForAccount(
|
|
174
|
+
accountId: string,
|
|
175
|
+
reason: CodexAffinityReason = "unusable",
|
|
176
|
+
): void {
|
|
177
|
+
for (const [threadId, affinities] of threadAccountMap) {
|
|
178
|
+
for (const [scope, entry] of affinities) {
|
|
179
|
+
if (entry.accountId === accountId && affinities.delete(scope)) {
|
|
180
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
181
|
+
notePendingReleaseReason(threadId, reason);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function pruneConversationStateIssuers(now: number): void {
|
|
189
|
+
for (const [key, entry] of conversationStateIssuerMap) {
|
|
190
|
+
if (now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS) {
|
|
191
|
+
conversationStateIssuerMap.delete(key);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
while (conversationStateIssuerMap.size > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
|
|
195
|
+
let oldestKey: string | null = null;
|
|
196
|
+
let oldestAt = Number.POSITIVE_INFINITY;
|
|
197
|
+
for (const [key, entry] of conversationStateIssuerMap) {
|
|
198
|
+
if (entry.lastUsedAt < oldestAt) {
|
|
199
|
+
oldestAt = entry.lastUsedAt;
|
|
200
|
+
oldestKey = key;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!oldestKey) break;
|
|
204
|
+
conversationStateIssuerMap.delete(oldestKey);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Record the pool account that just issued carried conversation state for this
|
|
210
|
+
* binding key. In-memory only; the id is never written to a request log.
|
|
211
|
+
*/
|
|
212
|
+
export function rememberConversationStateIssuer(
|
|
213
|
+
bindingKey: string,
|
|
214
|
+
accountId: string,
|
|
215
|
+
now = Date.now(),
|
|
216
|
+
): void {
|
|
217
|
+
if (!bindingKey.trim() || !accountId.trim()) return;
|
|
218
|
+
if (!admissibleAffinityComponent(bindingKey) || !admissibleAffinityComponent(accountId)) return;
|
|
219
|
+
pruneConversationStateIssuers(now);
|
|
220
|
+
conversationStateIssuerMap.set(bindingKey, { accountId, lastUsedAt: now });
|
|
221
|
+
pruneConversationStateIssuers(now);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Last account that minted carried state for this binding, if still in the TTL window. */
|
|
225
|
+
export function peekConversationStateIssuer(
|
|
226
|
+
bindingKey: string,
|
|
227
|
+
now = Date.now(),
|
|
228
|
+
): string | undefined {
|
|
229
|
+
if (!bindingKey.trim() || !admissibleAffinityComponent(bindingKey)) return undefined;
|
|
230
|
+
pruneConversationStateIssuers(now);
|
|
231
|
+
const entry = conversationStateIssuerMap.get(bindingKey);
|
|
232
|
+
if (!entry) return undefined;
|
|
233
|
+
entry.lastUsedAt = now;
|
|
234
|
+
return entry.accountId;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Why a binding was released, held until that thread's next resolve can report it (#4546).
|
|
239
|
+
*
|
|
240
|
+
* A release and the request that pays for it are two different moments: a 429 clears the pin
|
|
241
|
+
* inside the outcome recorder, and the next request arrives with nothing left to explain why it
|
|
242
|
+
* is starting cold. Bounded, because it is a diagnostic and must not become a leak.
|
|
243
|
+
*/
|
|
244
|
+
const pendingReleaseReasons = new Map<string, CodexAffinityReason>();
|
|
245
|
+
const MAX_PENDING_RELEASE_REASONS = 4096;
|
|
246
|
+
|
|
247
|
+
function notePendingReleaseReason(threadId: string | null, reason: CodexAffinityReason): void {
|
|
248
|
+
if (threadId === null) return;
|
|
249
|
+
if (!pendingReleaseReasons.has(threadId) && pendingReleaseReasons.size >= MAX_PENDING_RELEASE_REASONS) {
|
|
250
|
+
const oldest = pendingReleaseReasons.keys().next();
|
|
251
|
+
if (!oldest.done) pendingReleaseReasons.delete(oldest.value);
|
|
252
|
+
}
|
|
253
|
+
pendingReleaseReasons.set(threadId, reason);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function peekPendingReleaseReason(threadId: string | null): CodexAffinityReason | undefined {
|
|
257
|
+
if (threadId === null) return undefined;
|
|
258
|
+
return pendingReleaseReasons.get(threadId);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Forget a release only once it has actually been reported.
|
|
263
|
+
*
|
|
264
|
+
* Consuming it at derivation time lost it whenever selection then failed to produce an account:
|
|
265
|
+
* a no-account return carries no payload, so the release went unrecorded and the next successful
|
|
266
|
+
* resolve claimed a fresh healthy bind (#4598). A release survives until some resolve reports it.
|
|
267
|
+
*/
|
|
268
|
+
function clearPendingReleaseReason(threadId: string | null): void {
|
|
269
|
+
if (threadId !== null) pendingReleaseReasons.delete(threadId);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function threadAffinityScope(quotaScope?: CodexQuotaScope): BaseThreadAffinityScope {
|
|
273
|
+
return quotaScope ?? LEGACY_THREAD_AFFINITY_SCOPE;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function admissibleAffinityComponent(value: string): boolean {
|
|
277
|
+
return retainedUtf8Bytes(value) <= MAX_AFFINITY_COMPONENT_BYTES;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function modelDetourAffinityScope(
|
|
281
|
+
modelId: string | undefined,
|
|
282
|
+
quotaScope?: CodexQuotaScope,
|
|
283
|
+
): ModelDetourAffinityScope | undefined {
|
|
284
|
+
const canonicalModelId = modelId?.trim().toLowerCase();
|
|
285
|
+
if (!canonicalModelId || !admissibleAffinityComponent(canonicalModelId)) return undefined;
|
|
286
|
+
return `model-detour:${threadAffinityScope(quotaScope)}:${canonicalModelId}`;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getThreadAffinityForScope(
|
|
290
|
+
threadId: string,
|
|
291
|
+
scope: ThreadAffinityScope,
|
|
292
|
+
): ThreadAffinityEntry | undefined {
|
|
293
|
+
if (!admissibleAffinityComponent(threadId)) return undefined;
|
|
294
|
+
return threadAccountMap.get(threadId)?.get(scope);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function getThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): ThreadAffinityEntry | undefined {
|
|
298
|
+
return getThreadAffinityForScope(threadId, threadAffinityScope(quotaScope));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export function getModelDetourAffinity(
|
|
302
|
+
threadId: string,
|
|
303
|
+
modelId: string | undefined,
|
|
304
|
+
quotaScope?: CodexQuotaScope,
|
|
305
|
+
): ThreadAffinityEntry | undefined {
|
|
306
|
+
const scope = modelDetourAffinityScope(modelId, quotaScope);
|
|
307
|
+
return scope ? getThreadAffinityForScope(threadId, scope) : undefined;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function deleteThreadAffinityForScope(threadId: string, scope: ThreadAffinityScope): void {
|
|
311
|
+
if (!admissibleAffinityComponent(threadId)) return;
|
|
312
|
+
const affinities = threadAccountMap.get(threadId);
|
|
313
|
+
if (!affinities) return;
|
|
314
|
+
if (affinities.delete(scope)) {
|
|
315
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
316
|
+
}
|
|
317
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function deleteThreadAffinity(threadId: string, quotaScope?: CodexQuotaScope): void {
|
|
321
|
+
deleteThreadAffinityForScope(threadId, threadAffinityScope(quotaScope));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function deleteModelDetourAffinity(
|
|
325
|
+
threadId: string,
|
|
326
|
+
modelId: string | undefined,
|
|
327
|
+
quotaScope?: CodexQuotaScope,
|
|
328
|
+
): void {
|
|
329
|
+
const scope = modelDetourAffinityScope(modelId, quotaScope);
|
|
330
|
+
if (scope) deleteThreadAffinityForScope(threadId, scope);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Remove only the matching failed account's affinities for one thread. */
|
|
334
|
+
export function deleteThreadAffinitiesForAccount(threadId: string, accountId: string): void {
|
|
335
|
+
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
336
|
+
const affinities = threadAccountMap.get(threadId);
|
|
337
|
+
if (!affinities) return;
|
|
338
|
+
for (const [scope, entry] of affinities) {
|
|
339
|
+
if (entry.accountId === accountId && affinities.delete(scope)) {
|
|
340
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function threadAffinityEntryCount(): number {
|
|
347
|
+
return threadAffinityEntryTotal;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function isThreadAffinityExpired(entry: ThreadAffinityEntry, now: number): boolean {
|
|
351
|
+
return now - entry.lastUsedAt > CODEX_THREAD_AFFINITY_IDLE_TTL_MS;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export function isThreadAffinityGenerationLive(entry: ThreadAffinityEntry): boolean {
|
|
355
|
+
if (entry.accountId === MAIN_CODEX_ACCOUNT_ID) return entry.generation === 0;
|
|
356
|
+
return isCodexAccountGenerationLive(entry.accountId, entry.generation);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Generations this account's affinity entries are bound at. Test observability only. */
|
|
360
|
+
export function debugCodexAffinityGenerations(accountId: string): number[] {
|
|
361
|
+
const generations: number[] = [];
|
|
362
|
+
for (const affinities of threadAccountMap.values()) {
|
|
363
|
+
for (const entry of affinities.values()) {
|
|
364
|
+
if (entry.accountId === accountId) generations.push(entry.generation);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return generations;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Advance this account's affinity entries from the generation a rejected credential
|
|
372
|
+
* was bound under to the generation its own refresh produced.
|
|
373
|
+
*
|
|
374
|
+
* A 401 refresh-and-replay keeps the request on the same account, but the CAS write
|
|
375
|
+
* moves the credential from G to G+1, and {@link isThreadAffinityGenerationLive}
|
|
376
|
+
* demands exact equality — so without this the entry the replay just preserved is
|
|
377
|
+
* dead on the next request. Not quarantining an account is not the same as keeping
|
|
378
|
+
* its affinity.
|
|
379
|
+
*
|
|
380
|
+
* Lineage is proven by the CALLER, which must pass only a generation its own refresh
|
|
381
|
+
* produced. Re-deriving it here from `replacedAt` cannot work: the caller reads that
|
|
382
|
+
* field after the refresh and this function would re-read the same record, so the
|
|
383
|
+
* comparison is tautological and an external replacement passes it. An external
|
|
384
|
+
* replacement must retire the affinity, because that credential may belong to a
|
|
385
|
+
* different upstream identity.
|
|
386
|
+
*/
|
|
387
|
+
export function handOffThreadAffinityGeneration(
|
|
388
|
+
accountId: string,
|
|
389
|
+
fromGeneration: number,
|
|
390
|
+
toGeneration: number,
|
|
391
|
+
): boolean {
|
|
392
|
+
if (accountId === MAIN_CODEX_ACCOUNT_ID) return false;
|
|
393
|
+
if (toGeneration !== fromGeneration + 1) return false;
|
|
394
|
+
const record = readCodexAccountRecord(accountId);
|
|
395
|
+
if (!record?.credential || record.deletedAt != null) return false;
|
|
396
|
+
if (record.generation !== toGeneration) return false;
|
|
397
|
+
let handedOff = false;
|
|
398
|
+
for (const affinities of threadAccountMap.values()) {
|
|
399
|
+
for (const entry of affinities.values()) {
|
|
400
|
+
if (entry.accountId !== accountId || entry.generation !== fromGeneration) continue;
|
|
401
|
+
entry.generation = toGeneration;
|
|
402
|
+
handedOff = true;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return handedOff;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function pruneExpiredThreadAffinities(now: number): void {
|
|
409
|
+
for (const [threadId, affinities] of threadAccountMap) {
|
|
410
|
+
for (const [scope, entry] of affinities) {
|
|
411
|
+
if (isThreadAffinityExpired(entry, now) && affinities.delete(scope)) {
|
|
412
|
+
threadAffinityEntryTotal = Math.max(0, threadAffinityEntryTotal - 1);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
if (affinities.size === 0) threadAccountMap.delete(threadId);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function pruneLruThreadAffinities(): void {
|
|
420
|
+
if (threadAffinityEntryCount() <= CODEX_THREAD_AFFINITY_MAX_ENTRIES) return;
|
|
421
|
+
while (threadAffinityEntryCount() > CODEX_THREAD_AFFINITY_MAX_ENTRIES) {
|
|
422
|
+
let oldestThreadId: string | null = null;
|
|
423
|
+
let oldestScope: ThreadAffinityScope | null = null;
|
|
424
|
+
let oldestLastUsedAt = Number.POSITIVE_INFINITY;
|
|
425
|
+
let oldestIsDetour = false;
|
|
426
|
+
for (const [threadId, affinities] of threadAccountMap) {
|
|
427
|
+
for (const [scope, entry] of affinities) {
|
|
428
|
+
const candidateIsDetour = isModelDetourAffinityScope(scope);
|
|
429
|
+
if (
|
|
430
|
+
(candidateIsDetour && !oldestIsDetour)
|
|
431
|
+
|| (candidateIsDetour === oldestIsDetour && entry.lastUsedAt < oldestLastUsedAt)
|
|
432
|
+
) {
|
|
433
|
+
oldestThreadId = threadId;
|
|
434
|
+
oldestScope = scope;
|
|
435
|
+
oldestLastUsedAt = entry.lastUsedAt;
|
|
436
|
+
oldestIsDetour = candidateIsDetour;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (!oldestThreadId || !oldestScope) return;
|
|
441
|
+
deleteThreadAffinityForScope(oldestThreadId, oldestScope);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function bindThreadAffinityForScope(
|
|
446
|
+
threadId: string,
|
|
447
|
+
accountId: string,
|
|
448
|
+
now: number,
|
|
449
|
+
scope: ThreadAffinityScope,
|
|
450
|
+
): void {
|
|
451
|
+
if (!admissibleAffinityComponent(threadId) || !admissibleAffinityComponent(accountId)) return;
|
|
452
|
+
const record = accountId === MAIN_CODEX_ACCOUNT_ID ? undefined : readCodexAccountRecord(accountId);
|
|
453
|
+
if (accountId !== MAIN_CODEX_ACCOUNT_ID && (!record?.credential || record.deletedAt != null)) return;
|
|
454
|
+
pruneExpiredThreadAffinities(now);
|
|
455
|
+
const affinities = threadAccountMap.get(threadId) ?? new Map<ThreadAffinityScope, ThreadAffinityEntry>();
|
|
456
|
+
const previous = affinities.get(scope);
|
|
457
|
+
affinities.set(scope, {
|
|
458
|
+
accountId,
|
|
459
|
+
generation: accountId === MAIN_CODEX_ACCOUNT_ID ? 0 : record!.generation,
|
|
460
|
+
createdAt: previous?.createdAt ?? now,
|
|
461
|
+
lastUsedAt: now,
|
|
462
|
+
lastReevalAt: now,
|
|
463
|
+
});
|
|
464
|
+
if (!previous) threadAffinityEntryTotal += 1;
|
|
465
|
+
threadAccountMap.set(threadId, affinities);
|
|
466
|
+
pruneLruThreadAffinities();
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function bindThreadAffinity(
|
|
470
|
+
threadId: string,
|
|
471
|
+
accountId: string,
|
|
472
|
+
now: number,
|
|
473
|
+
quotaScope?: CodexQuotaScope,
|
|
474
|
+
): void {
|
|
475
|
+
bindThreadAffinityForScope(threadId, accountId, now, threadAffinityScope(quotaScope));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function bindModelDetourAffinity(
|
|
479
|
+
threadId: string,
|
|
480
|
+
accountId: string,
|
|
481
|
+
now: number,
|
|
482
|
+
modelId: string | undefined,
|
|
483
|
+
quotaScope?: CodexQuotaScope,
|
|
484
|
+
): void {
|
|
485
|
+
const scope = modelDetourAffinityScope(modelId, quotaScope);
|
|
486
|
+
if (scope) bindThreadAffinityForScope(threadId, accountId, now, scope);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Read-only view of one thread's scope-keyed affinity entries. */
|
|
490
|
+
export function getThreadAffinityScopes(
|
|
491
|
+
threadId: string,
|
|
492
|
+
): ReadonlyMap<ThreadAffinityScope, ThreadAffinityEntry> | undefined {
|
|
493
|
+
return threadAccountMap.get(threadId);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Move one scope's binding from the pre-#4546 RAW parent key onto the key this thread uses now.
|
|
498
|
+
*
|
|
499
|
+
* Bindings and the key that derives them are process-local, so an ordinary restart already
|
|
500
|
+
* discards every binding and there is nothing to migrate. The case this exists for is the
|
|
501
|
+
* narrow one: a code swap under a live conversation, where the map still holds entries made by
|
|
502
|
+
* the old rule. Rebinding those cold is precisely the defect the lineage work exists to prevent,
|
|
503
|
+
* so the conversation keeps its account and the legacy entry is retired in the same step.
|
|
504
|
+
*
|
|
505
|
+
* One way, once. The legacy entry is deleted even when it was dead on arrival, because nothing
|
|
506
|
+
* can reach it again under the new rule and an orphan only spends an LRU slot a live
|
|
507
|
+
* conversation needs. Only the account moves: a transient hold describes a failure happening
|
|
508
|
+
* right now, and the ordinary path re-derives it on this very request.
|
|
509
|
+
*/
|
|
510
|
+
function adoptLegacyAffinityForScope(
|
|
511
|
+
threadId: string,
|
|
512
|
+
legacyKey: string,
|
|
513
|
+
now: number,
|
|
514
|
+
scope: ThreadAffinityScope,
|
|
515
|
+
): void {
|
|
516
|
+
if (getThreadAffinityForScope(threadId, scope) !== undefined) return;
|
|
517
|
+
const legacy = getThreadAffinityForScope(legacyKey, scope);
|
|
518
|
+
if (legacy === undefined) return;
|
|
519
|
+
if (!isThreadAffinityExpired(legacy, now) && isThreadAffinityGenerationLive(legacy)) {
|
|
520
|
+
bindThreadAffinityForScope(threadId, legacy.accountId, now, scope);
|
|
521
|
+
}
|
|
522
|
+
deleteThreadAffinityForScope(legacyKey, scope);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Both lanes of the legacy migration: the ordinary binding and this request's model detour. */
|
|
526
|
+
export function adoptLegacyLineageAffinity(
|
|
527
|
+
threadId: string,
|
|
528
|
+
lineage: CodexThreadLineage | undefined,
|
|
529
|
+
now: number,
|
|
530
|
+
quotaScope?: CodexQuotaScope,
|
|
531
|
+
modelId?: string,
|
|
532
|
+
): void {
|
|
533
|
+
const legacyKey = lineage?.legacyConversationKey;
|
|
534
|
+
if (legacyKey === undefined || legacyKey === threadId) return;
|
|
535
|
+
adoptLegacyAffinityForScope(threadId, legacyKey, now, threadAffinityScope(quotaScope));
|
|
536
|
+
const detourScope = modelDetourAffinityScope(modelId, quotaScope);
|
|
537
|
+
if (detourScope) adoptLegacyAffinityForScope(threadId, legacyKey, now, detourScope);
|
|
538
|
+
}
|