@bitkyc08/opencodex 2.56.0 → 2.58.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-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
- package/src/adapters/command-code.ts +12 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/google.ts +7 -7
- 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/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +62 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +8 -2
- package/src/bridge/sse.ts +20 -2
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +32 -4
- 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/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +92 -7
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +74 -5
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +199 -15
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- 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/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +231 -32
- package/src/codex/inject.ts +12 -16
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- 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/atomic-write.ts +83 -8
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +272 -180
- package/src/images/loop.ts +1 -1
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -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 +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/derive.ts +6 -0
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +35 -7
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +59 -28
- package/src/providers/registry/model-seeds.ts +71 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +54 -3
- package/src/server/index.ts +5 -5
- 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/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- 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/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +129 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +30 -6
- package/src/server/responses/passthrough-dispatch.ts +61 -11
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +173 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- 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 +8 -1
- package/src/types/provider.ts +13 -0
- 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 +1 -1
- 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
|
@@ -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;
|
|
@@ -308,6 +308,47 @@ function observeOwner(entry: StartupEntry, owner: NativeMainOwnerSnapshot): void
|
|
|
308
308
|
* Retain process ownership for one live server. The first reference acquires the
|
|
309
309
|
* canonical-home SQLite lease and owns recovery; later same-process references share it.
|
|
310
310
|
*/
|
|
311
|
+
/**
|
|
312
|
+
* Releases nobody is awaiting.
|
|
313
|
+
*
|
|
314
|
+
* A release closes the native-main owner's SQLite lease and its stable lock file, both of which
|
|
315
|
+
* live under CODEX_HOME. The normal shutdown path awaits it: `server.stop` goes through
|
|
316
|
+
* `releaseNativeMainStartupLifecycle`, which awaits the flight. The FAILED-start path does not —
|
|
317
|
+
* `startServer` must stay synchronous, so its rollback can only fire `void lifecycle.release()`
|
|
318
|
+
* and rethrow. Nothing could then wait for those handles to close, and on Windows an open handle
|
|
319
|
+
* does not delay an unlink, it refuses it outright with EPERM.
|
|
320
|
+
*
|
|
321
|
+
* That is invisible in production, where a failed start is followed by exit rather than by
|
|
322
|
+
* deleting the home. It is not invisible to a test whose cleanup removes the home it just used:
|
|
323
|
+
* `tests/server/server-management-auth.test.ts` binds a management ingress on the fixed port
|
|
324
|
+
* 10101, which nine other test files also use, so a collision on the six-shard Windows leg turns
|
|
325
|
+
* a passing start into the rollback path. The failure followed that collision across shards 1, 2
|
|
326
|
+
* and 3 while staying on the same file and line, which is what a shard-independent trigger looks
|
|
327
|
+
* like.
|
|
328
|
+
*
|
|
329
|
+
* Tracking the flight here rather than at the call site keeps `startServer` synchronous and
|
|
330
|
+
* unchanged, and gives anyone who needs the handles closed something to await.
|
|
331
|
+
*/
|
|
332
|
+
const pendingStartupReleases = new Set<Promise<void>>();
|
|
333
|
+
|
|
334
|
+
function trackStartupRelease(release: Promise<void>): Promise<void> {
|
|
335
|
+
const tracked = release.finally(() => { pendingStartupReleases.delete(tracked); });
|
|
336
|
+
pendingStartupReleases.add(tracked);
|
|
337
|
+
return tracked;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Settle every native-main startup release still in flight, including ones nobody awaited.
|
|
342
|
+
*
|
|
343
|
+
* Loops rather than awaiting a single snapshot: a release can retire an owner whose own teardown
|
|
344
|
+
* starts another, and draining only the first batch would return with handles still open.
|
|
345
|
+
*/
|
|
346
|
+
export async function flushNativeMainStartupReleases(): Promise<void> {
|
|
347
|
+
while (pendingStartupReleases.size > 0) {
|
|
348
|
+
await Promise.allSettled([...pendingStartupReleases]);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
311
352
|
export function startNativeMainStartupLifecycle(
|
|
312
353
|
deps: NativeMainStartupGateDeps = {},
|
|
313
354
|
): NativeMainStartupLifecycle {
|
|
@@ -352,29 +393,32 @@ export function startNativeMainStartupLifecycle(
|
|
|
352
393
|
}
|
|
353
394
|
entry.refs += 1;
|
|
354
395
|
let released = false;
|
|
396
|
+
const performRelease = async (): Promise<void> => {
|
|
397
|
+
if (released) return;
|
|
398
|
+
released = true;
|
|
399
|
+
entry!.refs = Math.max(0, entry!.refs - 1);
|
|
400
|
+
if (entry!.refs !== 0) return;
|
|
401
|
+
entry!.epoch += 1;
|
|
402
|
+
entry!.sweepStopping = true;
|
|
403
|
+
if (entry!.sweepTimer) clearTimeout(entry!.sweepTimer);
|
|
404
|
+
entry!.sweepTimer = undefined;
|
|
405
|
+
entry!.unsubscribe();
|
|
406
|
+
startupEntries.delete(homeId);
|
|
407
|
+
entry!.resolveAcquisition?.(snapshot);
|
|
408
|
+
entry!.resolveAcquisition = undefined;
|
|
409
|
+
// Startup convergence can transition from the exclusive recovery claim
|
|
410
|
+
// into a stage sweep. Keep the owner registered until that entire chain
|
|
411
|
+
// settles so no cleanup transaction starts untracked after owner detach.
|
|
412
|
+
await Promise.allSettled([entry!.settled]);
|
|
413
|
+
if (entry!.sweepInFlight) await Promise.allSettled([entry!.sweepInFlight]);
|
|
414
|
+
await entry!.owner.release();
|
|
415
|
+
};
|
|
355
416
|
return {
|
|
356
417
|
homeId,
|
|
357
418
|
get settled() { return entry!.settled; },
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
entry!.refs = Math.max(0, entry!.refs - 1);
|
|
362
|
-
if (entry!.refs !== 0) return;
|
|
363
|
-
entry!.epoch += 1;
|
|
364
|
-
entry!.sweepStopping = true;
|
|
365
|
-
if (entry!.sweepTimer) clearTimeout(entry!.sweepTimer);
|
|
366
|
-
entry!.sweepTimer = undefined;
|
|
367
|
-
entry!.unsubscribe();
|
|
368
|
-
startupEntries.delete(homeId);
|
|
369
|
-
entry!.resolveAcquisition?.(snapshot);
|
|
370
|
-
entry!.resolveAcquisition = undefined;
|
|
371
|
-
// Startup convergence can transition from the exclusive recovery claim
|
|
372
|
-
// into a stage sweep. Keep the owner registered until that entire chain
|
|
373
|
-
// settles so no cleanup transaction starts untracked after owner detach.
|
|
374
|
-
await Promise.allSettled([entry!.settled]);
|
|
375
|
-
if (entry!.sweepInFlight) await Promise.allSettled([entry!.sweepInFlight]);
|
|
376
|
-
await entry!.owner.release();
|
|
377
|
-
},
|
|
419
|
+
// Tracked so a caller that cannot await -- the synchronous rollback in `startServer` -- still
|
|
420
|
+
// leaves the flight drainable through `flushNativeMainStartupReleases`.
|
|
421
|
+
release: () => trackStartupRelease(performRelease()),
|
|
378
422
|
};
|
|
379
423
|
}
|
|
380
424
|
|
|
@@ -43,6 +43,12 @@ const backoffByAccount = new Map<string, RefreshFailureBackoff>();
|
|
|
43
43
|
* must not re-quarantine the credential that replaced it.
|
|
44
44
|
*/
|
|
45
45
|
const fenceByAccount = new Map<string, number>();
|
|
46
|
+
/**
|
|
47
|
+
* Invalidates every account fence without having to know which refresh flights are currently in
|
|
48
|
+
* progress. A bulk routing-state reset can race a first failure for an account that has no map
|
|
49
|
+
* entry yet, so iterating either map cannot close this boundary.
|
|
50
|
+
*/
|
|
51
|
+
let globalFence = 0;
|
|
46
52
|
let nowOverride: number | undefined;
|
|
47
53
|
|
|
48
54
|
export function setCodexPoolRefreshFailureNowForTests(now?: number): void {
|
|
@@ -52,12 +58,13 @@ export function setCodexPoolRefreshFailureNowForTests(now?: number): void {
|
|
|
52
58
|
export function resetCodexPoolRefreshFailureBackoffForTests(): void {
|
|
53
59
|
backoffByAccount.clear();
|
|
54
60
|
fenceByAccount.clear();
|
|
61
|
+
globalFence = 0;
|
|
55
62
|
nowOverride = undefined;
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
/** The value a refresh flight captures before it starts, to be handed back on failure. */
|
|
59
|
-
export function codexPoolRefreshFence(accountId: string):
|
|
60
|
-
return fenceByAccount.get(accountId) ?? 0
|
|
66
|
+
export function codexPoolRefreshFence(accountId: string): string {
|
|
67
|
+
return `${globalFence}:${fenceByAccount.get(accountId) ?? 0}`;
|
|
61
68
|
}
|
|
62
69
|
|
|
63
70
|
export function clearCodexPoolRefreshFailure(accountId: string): void {
|
|
@@ -72,6 +79,8 @@ export function clearCodexPoolRefreshFailure(accountId: string): void {
|
|
|
72
79
|
*/
|
|
73
80
|
export function clearAllCodexPoolRefreshFailures(): void {
|
|
74
81
|
backoffByAccount.clear();
|
|
82
|
+
fenceByAccount.clear();
|
|
83
|
+
globalFence += 1;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
function currentNow(now?: number): number {
|
|
@@ -115,7 +124,7 @@ export function noteCodexPoolRefreshFailure(
|
|
|
115
124
|
accountId: string,
|
|
116
125
|
reason: string,
|
|
117
126
|
now = currentNow(),
|
|
118
|
-
fence?:
|
|
127
|
+
fence?: string,
|
|
119
128
|
): { consecutiveFailures: number; cooldownUntil: number; openedWindow: boolean } {
|
|
120
129
|
const existing = backoffByAccount.get(accountId);
|
|
121
130
|
// A flight that started before the account's failures were cleared is speaking for a grant
|
|
@@ -8,8 +8,41 @@ const RESET_ELIGIBLE_CODE_VALUES = [
|
|
|
8
8
|
export type CodexResetEligibleExhaustionCode =
|
|
9
9
|
(typeof RESET_ELIGIBLE_CODE_VALUES)[number];
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Upstream codes that name an ORGANIZATION- or PROJECT-scoped exhaustion (#4546).
|
|
13
|
+
*
|
|
14
|
+
* These are a different animal from the reset-eligible codes above, and the difference is the
|
|
15
|
+
* whole point. `usage_limit_exceeded` describes the account that was asked; another pool account
|
|
16
|
+
* carries its own plan allowance, so rotating to it is a real move. Every code here describes a
|
|
17
|
+
* limit the CREDENTIAL does not own -- a balance, a spend cap, or a usage cap held by the
|
|
18
|
+
* organization or project the credential belongs to. Two credentials inside that organization
|
|
19
|
+
* are refused by the same counter, so rotating between them pays a cold prompt prefix for zero
|
|
20
|
+
* new capacity, which is the send amplification this unit exists to stop.
|
|
21
|
+
*
|
|
22
|
+
* openai/codex reached the same classification from the client side: #44492 maps exactly these
|
|
23
|
+
* HTTP 429 codes to a terminal `QuotaExceeded` instead of a retry-limit failure, and #45602
|
|
24
|
+
* extends it to the SSE path while deliberately KEEPING `rate_limit_exceeded` and `slow_down`
|
|
25
|
+
* retryable. The platform documentation states the same rule for the whole class: "It does not
|
|
26
|
+
* mean that quota, billing, or other errors that require user action can be resolved by
|
|
27
|
+
* retrying."
|
|
28
|
+
*
|
|
29
|
+
* Membership here says nothing about reset credits. A reset credit reconciles a ChatGPT plan
|
|
30
|
+
* window; it cannot pay an organization's bill, so these codes are deliberately absent from
|
|
31
|
+
* {@link RESET_ELIGIBLE_CODE_VALUES} and never set `resetCreditEligible`.
|
|
32
|
+
*/
|
|
33
|
+
const SCOPED_EXHAUSTION_CODE_VALUES = [
|
|
34
|
+
"credit_balance_exhausted",
|
|
35
|
+
"organization_spend_limit_exceeded",
|
|
36
|
+
"project_spend_limit_exceeded",
|
|
37
|
+
"organization_usage_limit_exceeded",
|
|
38
|
+
] as const;
|
|
39
|
+
|
|
40
|
+
export type CodexScopedExhaustionCode =
|
|
41
|
+
(typeof SCOPED_EXHAUSTION_CODE_VALUES)[number];
|
|
42
|
+
|
|
11
43
|
export type CodexPreStreamRejectionKind =
|
|
12
44
|
| "reset-eligible-exhaustion"
|
|
45
|
+
| "scoped-quota-exhaustion"
|
|
13
46
|
| "generic-rate-limit"
|
|
14
47
|
| "unverified-billing-or-quota"
|
|
15
48
|
| "transient-server-error"
|
|
@@ -23,6 +56,12 @@ export interface CodexPreStreamRejection {
|
|
|
23
56
|
alternateRetryEligible: boolean;
|
|
24
57
|
resetCreditEligible: boolean;
|
|
25
58
|
semanticCode?: CodexResetEligibleExhaustionCode;
|
|
59
|
+
/**
|
|
60
|
+
* The organization- or project-scoped exhaustion code the upstream body named, when it named
|
|
61
|
+
* one. Never accompanied by `semanticCode`: the two sets are disjoint, and only `semanticCode`
|
|
62
|
+
* may authorize a reset credit.
|
|
63
|
+
*/
|
|
64
|
+
scopedExhaustionCode?: CodexScopedExhaustionCode;
|
|
26
65
|
/**
|
|
27
66
|
* Structured denial evidence for a 403. Present only when the upstream body names a
|
|
28
67
|
* workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
|
|
@@ -97,6 +136,7 @@ function structuredDenialCode(payload: unknown): string | undefined {
|
|
|
97
136
|
}
|
|
98
137
|
|
|
99
138
|
const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
|
|
139
|
+
const SCOPED_EXHAUSTION_CODES: ReadonlySet<string> = new Set(SCOPED_EXHAUSTION_CODE_VALUES);
|
|
100
140
|
|
|
101
141
|
const TRANSIENT_SERVER_STATUSES = new Set([500, 502, 503, 504, 520, 521, 522]);
|
|
102
142
|
const JSON_NUMBER_PATTERN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
@@ -107,6 +147,7 @@ function rejection(
|
|
|
107
147
|
options: {
|
|
108
148
|
alternateRetryEligible?: boolean;
|
|
109
149
|
semanticCode?: CodexResetEligibleExhaustionCode;
|
|
150
|
+
scopedExhaustionCode?: CodexScopedExhaustionCode;
|
|
110
151
|
} = {},
|
|
111
152
|
): CodexPreStreamRejection {
|
|
112
153
|
return {
|
|
@@ -115,6 +156,7 @@ function rejection(
|
|
|
115
156
|
alternateRetryEligible: options.alternateRetryEligible === true,
|
|
116
157
|
resetCreditEligible: options.semanticCode !== undefined,
|
|
117
158
|
...(options.semanticCode ? { semanticCode: options.semanticCode } : {}),
|
|
159
|
+
...(options.scopedExhaustionCode ? { scopedExhaustionCode: options.scopedExhaustionCode } : {}),
|
|
118
160
|
};
|
|
119
161
|
}
|
|
120
162
|
|
|
@@ -210,9 +252,19 @@ function isUnsafeJsonDocument(text: string): boolean {
|
|
|
210
252
|
}
|
|
211
253
|
}
|
|
212
254
|
|
|
213
|
-
|
|
255
|
+
/**
|
|
256
|
+
* Read the one exact, unambiguous code a container declares, and only if it is in `allowed`.
|
|
257
|
+
*
|
|
258
|
+
* Generic over the allowed set so the reset-eligible and organization-scoped classifications
|
|
259
|
+
* share one parser. They must: the strictness here -- a `code`/`type` pair that disagrees is
|
|
260
|
+
* rejected rather than resolved, and no trimming or case folding is applied -- is what keeps a
|
|
261
|
+
* near-miss from being read as an exact upstream code, and a second hand-written copy would
|
|
262
|
+
* drift away from that.
|
|
263
|
+
*/
|
|
264
|
+
function exactAllowedCode(
|
|
214
265
|
container: Record<string, unknown>,
|
|
215
|
-
|
|
266
|
+
allowed: ReadonlySet<string>,
|
|
267
|
+
): string | undefined {
|
|
216
268
|
const hasCode = hasOwnField(container, "code");
|
|
217
269
|
const hasType = hasOwnField(container, "type");
|
|
218
270
|
if (!hasCode && !hasType) return undefined;
|
|
@@ -226,49 +278,80 @@ function exactResetEligibleCode(
|
|
|
226
278
|
|
|
227
279
|
const value = hasCode ? code : type;
|
|
228
280
|
if (typeof value !== "string") return undefined;
|
|
229
|
-
return
|
|
230
|
-
? value as CodexResetEligibleExhaustionCode
|
|
231
|
-
: undefined;
|
|
281
|
+
return allowed.has(value) ? value : undefined;
|
|
232
282
|
}
|
|
233
283
|
|
|
234
|
-
function
|
|
284
|
+
function structuredAllowedCode(payload: unknown, allowed: ReadonlySet<string>): string | undefined {
|
|
235
285
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
236
286
|
const root = payload as Record<string, unknown>;
|
|
237
287
|
const hasRootDiscriminator = hasOwnField(root, "code") || hasOwnField(root, "type");
|
|
238
288
|
|
|
239
|
-
if (!hasOwnField(root, "error")) return
|
|
289
|
+
if (!hasOwnField(root, "error")) return exactAllowedCode(root, allowed);
|
|
240
290
|
if (hasRootDiscriminator) return undefined;
|
|
241
291
|
|
|
242
292
|
const nested = root.error;
|
|
243
293
|
if (!nested || typeof nested !== "object" || Array.isArray(nested)) return undefined;
|
|
244
|
-
return
|
|
294
|
+
return exactAllowedCode(nested as Record<string, unknown>, allowed);
|
|
245
295
|
}
|
|
246
296
|
|
|
247
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Parse one bounded body and classify its structured code against both sets at once.
|
|
299
|
+
*
|
|
300
|
+
* One read, because the caller holds a `Response` whose body may only be consumed once per
|
|
301
|
+
* clone and the two questions are asked about the same bytes.
|
|
302
|
+
*/
|
|
303
|
+
async function exhaustionCodeFromResponse(
|
|
248
304
|
response: Response,
|
|
249
305
|
signal?: AbortSignal,
|
|
250
|
-
): Promise<
|
|
306
|
+
): Promise<{
|
|
307
|
+
resetEligible?: CodexResetEligibleExhaustionCode;
|
|
308
|
+
scoped?: CodexScopedExhaustionCode;
|
|
309
|
+
}> {
|
|
251
310
|
try {
|
|
252
311
|
const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
|
|
253
|
-
if (!body.displaySafe || body.truncated || !body.text.trim()) return
|
|
312
|
+
if (!body.displaySafe || body.truncated || !body.text.trim()) return {};
|
|
254
313
|
const payload = JSON.parse(body.text) as unknown;
|
|
255
314
|
// JSON.parse silently keeps the last duplicate key, making contradictory
|
|
256
315
|
// payloads order-dependent. Reject any duplicate at any object depth.
|
|
257
|
-
if (isUnsafeJsonDocument(body.text)) return
|
|
258
|
-
|
|
316
|
+
if (isUnsafeJsonDocument(body.text)) return {};
|
|
317
|
+
const resetEligible = structuredAllowedCode(payload, RESET_ELIGIBLE_CODES);
|
|
318
|
+
if (resetEligible !== undefined) {
|
|
319
|
+
return { resetEligible: resetEligible as CodexResetEligibleExhaustionCode };
|
|
320
|
+
}
|
|
321
|
+
const scoped = structuredAllowedCode(payload, SCOPED_EXHAUSTION_CODES);
|
|
322
|
+
return scoped === undefined ? {} : { scoped: scoped as CodexScopedExhaustionCode };
|
|
259
323
|
} catch {
|
|
260
324
|
// Classification must fail closed. A malformed, oversized, consumed, or
|
|
261
325
|
// cancelled body cannot authorize an irreversible reset-credit operation.
|
|
262
|
-
return
|
|
326
|
+
return {};
|
|
263
327
|
}
|
|
264
328
|
}
|
|
265
329
|
|
|
330
|
+
/**
|
|
331
|
+
* The organization- or project-scoped exhaustion code this rejection names, if any.
|
|
332
|
+
*
|
|
333
|
+
* Exported for the account-rotation gate, which has to answer "may another pool account serve
|
|
334
|
+
* this?" before it has any reason to build a full classification. Fails closed to `undefined`:
|
|
335
|
+
* an unreadable, truncated, duplicate-keyed or aborted body leaves the caller's existing
|
|
336
|
+
* behaviour untouched, so only positive evidence can ever withhold a rotation.
|
|
337
|
+
*/
|
|
338
|
+
export async function codexScopedExhaustionCode(
|
|
339
|
+
response: Response,
|
|
340
|
+
options: { signal?: AbortSignal } = {},
|
|
341
|
+
): Promise<CodexScopedExhaustionCode | undefined> {
|
|
342
|
+
return (await exhaustionCodeFromResponse(response, options.signal)).scoped;
|
|
343
|
+
}
|
|
344
|
+
|
|
266
345
|
/**
|
|
267
346
|
* Classify an upstream Codex rejection before any response event is exposed.
|
|
268
347
|
*
|
|
269
348
|
* Only an exact structured exhaustion code on HTTP 429/402 is reset-eligible.
|
|
270
349
|
* Status alone and message text are intentionally insufficient. The broad
|
|
271
350
|
* alternate-account retry remains eligible for 429/402 to preserve #584.
|
|
351
|
+
*
|
|
352
|
+
* The one carve-out from that breadth is an organization- or project-scoped exhaustion
|
|
353
|
+
* ({@link SCOPED_EXHAUSTION_CODE_VALUES}), which reports `alternateRetryEligible: false`
|
|
354
|
+
* because every credential inside the refusing limit would be refused by the same counter.
|
|
272
355
|
*/
|
|
273
356
|
export async function classifyCodexPreStreamRejection(
|
|
274
357
|
response: Response,
|
|
@@ -283,13 +366,19 @@ export async function classifyCodexPreStreamRejection(
|
|
|
283
366
|
if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
|
|
284
367
|
if (status !== 429 && status !== 402) return rejection(status, "other");
|
|
285
368
|
|
|
286
|
-
const semanticCode = await
|
|
369
|
+
const { resetEligible: semanticCode, scoped } = await exhaustionCodeFromResponse(
|
|
370
|
+
response,
|
|
371
|
+
options.signal,
|
|
372
|
+
);
|
|
287
373
|
if (semanticCode) {
|
|
288
374
|
return rejection(status, "reset-eligible-exhaustion", {
|
|
289
375
|
alternateRetryEligible: true,
|
|
290
376
|
semanticCode,
|
|
291
377
|
});
|
|
292
378
|
}
|
|
379
|
+
if (scoped) {
|
|
380
|
+
return rejection(status, "scoped-quota-exhaustion", { scopedExhaustionCode: scoped });
|
|
381
|
+
}
|
|
293
382
|
return rejection(
|
|
294
383
|
status,
|
|
295
384
|
status === 429 ? "generic-rate-limit" : "unverified-billing-or-quota",
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import type { CodexAccountUsabilityOptions } from "../account-usability";
|
|
3
|
+
import { isCodexAccountUsable } from "../account-usability";
|
|
4
|
+
import { isCacheAffinityEnabled, isUnknownUsage } from "./selection";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Does a healthy bound account keep its conversation when quota re-evaluation looks at it?
|
|
8
|
+
*
|
|
9
|
+
* Two independent reasons say yes, and they are not the same claim. `pool.cacheAffinity` is an
|
|
10
|
+
* operator preference about COST: provider prompt caches are account-isolated, so handing a bound
|
|
11
|
+
* conversation from account to account re-sends the whole prefix, and #4546 measured 7k-token
|
|
12
|
+
* turns becoming 150k-token ones. Setting it false restores capacity-first routing, and an
|
|
13
|
+
* operator who wants that keeps it.
|
|
14
|
+
*
|
|
15
|
+
* Uploaded-file retention is a claim about CORRECTNESS, so it does not take that instruction
|
|
16
|
+
* (#4778). Uploaded files are scoped to the account that issued them. Moving a conversation that
|
|
17
|
+
* carries live `file_id` references does not cost a cold prefix -- it orphans the reference, and
|
|
18
|
+
* because the reference stays in conversation history EVERY later turn is refused with
|
|
19
|
+
* `409 account_change_file_scope` until the user re-uploads under the serving account or starts
|
|
20
|
+
* over. That is a dead conversation rather than an expensive one, and `pool.cacheAffinity: false`
|
|
21
|
+
* was never asking to accept it: the flag trades cache locality for capacity, not correctness for
|
|
22
|
+
* capacity.
|
|
23
|
+
*
|
|
24
|
+
* This answers the VOLUNTARY move only. Its caller still releases the binding on genuine
|
|
25
|
+
* exhaustion or an unusable account, and every involuntary release that runs earlier in
|
|
26
|
+
* `resolveCodexAccountForThreadDetailed` -- quota refusal, failover streak, pause, cooldown, lost
|
|
27
|
+
* generation, affinity expiry -- never reaches here at all. So retention can never wedge a
|
|
28
|
+
* conversation on an account that cannot serve it, which is exactly why the #4710 refusal remains
|
|
29
|
+
* required: this makes that refusal rarer and does not replace it.
|
|
30
|
+
*
|
|
31
|
+
* It lives beside selection rather than inside `routing.ts` because it is a policy question two
|
|
32
|
+
* call sites ask -- the live path in `reevaluateAffinityQuota` and the side-effect-free
|
|
33
|
+
* `previewReusableAffinityAccount` that subagent fallback reads -- and those two must answer
|
|
34
|
+
* identically or preview hands fallback a different account than the request uses.
|
|
35
|
+
*/
|
|
36
|
+
export function retainsBoundAccountForQuota(
|
|
37
|
+
config: OcxConfig,
|
|
38
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
39
|
+
): boolean {
|
|
40
|
+
return isCacheAffinityEnabled(config)
|
|
41
|
+
|| selectionOptions?.retainAccountForUploadedFiles === true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* May a LIVE binding be moved for quota reasons?
|
|
46
|
+
*
|
|
47
|
+
* Default: no. The bar is genuine exhaustion, because moving a bound conversation discards the
|
|
48
|
+
* prompt cache warmed on its account and a threshold crossing is a hint that the account is
|
|
49
|
+
* getting busy rather than evidence it cannot serve (#4546). Deliberately NOT
|
|
50
|
+
* `hasCodexQuotaHeadroom`, which reads `usage < autoSwitchThreshold` and would reproduce the old
|
|
51
|
+
* rule under a new name.
|
|
52
|
+
*
|
|
53
|
+
* When nothing retains, the historical rule comes back: a crossing of `autoSwitchThreshold` is
|
|
54
|
+
* enough. That is capacity-first routing, and an operator who asks for it keeps it -- it is just
|
|
55
|
+
* not what an install gets by never having heard of the flag.
|
|
56
|
+
*/
|
|
57
|
+
export function mayRebindAffinityForQuota(
|
|
58
|
+
config: OcxConfig,
|
|
59
|
+
accountId: string,
|
|
60
|
+
usage: number,
|
|
61
|
+
threshold: number,
|
|
62
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
63
|
+
): boolean {
|
|
64
|
+
const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
|
|
65
|
+
if (!retainsBoundAccountForQuota(config, selectionOptions)) return overThreshold;
|
|
66
|
+
// The usable half is already guaranteed by both callers, which gate on
|
|
67
|
+
// isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
|
|
68
|
+
return !isCodexAccountUsable(config, accountId, selectionOptions)
|
|
69
|
+
|| (!isUnknownUsage(usage) && usage >= 100);
|
|
70
|
+
}
|