@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.
Files changed (206) hide show
  1. package/bin/ocx.mjs +10 -0
  2. package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
  3. package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +4 -4
  6. package/src/adapters/codebuddy/adapter.ts +2 -1
  7. package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
  8. package/src/adapters/command-code.ts +12 -3
  9. package/src/adapters/cursor/cursor-errors.ts +15 -0
  10. package/src/adapters/cursor/discovery.ts +65 -1
  11. package/src/adapters/cursor/envelope-echo.ts +8 -2
  12. package/src/adapters/cursor/live-transport.ts +5 -1
  13. package/src/adapters/cursor/protobuf-events.ts +110 -11
  14. package/src/adapters/cursor/protobuf-request.ts +19 -1
  15. package/src/adapters/cursor/text-toolcall.ts +230 -0
  16. package/src/adapters/cursor/thread-continuity.ts +67 -0
  17. package/src/adapters/cursor/types.ts +5 -0
  18. package/src/adapters/cursor.ts +55 -5
  19. package/src/adapters/google-http.ts +38 -13
  20. package/src/adapters/google.ts +7 -7
  21. package/src/adapters/kiro/payload.ts +17 -3
  22. package/src/adapters/kiro/reasoning.ts +70 -7
  23. package/src/adapters/kiro/stream.ts +8 -2
  24. package/src/adapters/kiro/wire.ts +2 -1
  25. package/src/adapters/kiro-events.ts +21 -13
  26. package/src/adapters/mimo-free.ts +32 -17
  27. package/src/adapters/ollama-native.ts +42 -8
  28. package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
  29. package/src/adapters/openai-chat/tool-schema.ts +25 -7
  30. package/src/adapters/openai-chat.ts +8 -8
  31. package/src/adapters/openai-responses/passthrough.ts +62 -5
  32. package/src/adapters/openai-responses/request-strips.ts +43 -0
  33. package/src/adapters/physical-send.ts +50 -0
  34. package/src/bridge/errors.ts +26 -2
  35. package/src/bridge/response-json.ts +8 -2
  36. package/src/bridge/sse.ts +20 -2
  37. package/src/claude/desktop-profile.ts +66 -9
  38. package/src/claude/outbound.ts +32 -4
  39. package/src/cli/account-main.ts +1 -1
  40. package/src/cli/capabilities.ts +2 -2
  41. package/src/cli/combo.ts +10 -1
  42. package/src/cli/config-command.ts +35 -18
  43. package/src/cli/dispatch.ts +17 -4
  44. package/src/cli/index.ts +92 -7
  45. package/src/cli/registry.ts +2 -1
  46. package/src/cli/system-command.ts +74 -5
  47. package/src/cli/uninstall-client-state.ts +12 -0
  48. package/src/clients/config-export.ts +7 -3
  49. package/src/codex/account-label.ts +14 -3
  50. package/src/codex/account-store.ts +113 -26
  51. package/src/codex/account-usability.ts +21 -0
  52. package/src/codex/auth-api/login-flow.ts +14 -2
  53. package/src/codex/auth-api/reset-credit-service.ts +11 -2
  54. package/src/codex/auth-context.ts +199 -15
  55. package/src/codex/catalog/aggregation.ts +80 -1
  56. package/src/codex/catalog/model-visibility.ts +1 -0
  57. package/src/codex/catalog/remote.ts +30 -0
  58. package/src/codex/catalog/retained-sync.ts +9 -1
  59. package/src/codex/catalog/routed-gather.ts +38 -1
  60. package/src/codex/cli-install-provenance.ts +7 -1
  61. package/src/codex/convergence.ts +7 -2
  62. package/src/codex/desktop-app/types.ts +11 -2
  63. package/src/codex/desktop-app/windows.ts +5 -5
  64. package/src/codex/desktop-switches.ts +145 -0
  65. package/src/codex/history-job.ts +5 -1
  66. package/src/codex/history-provider.ts +33 -4
  67. package/src/codex/history-worker.ts +14 -1
  68. package/src/codex/inject/remove.ts +145 -7
  69. package/src/codex/inject/restore.ts +231 -32
  70. package/src/codex/inject.ts +12 -16
  71. package/src/codex/loopback-target.ts +9 -0
  72. package/src/codex/model-entitlements.ts +152 -15
  73. package/src/codex/native-profile-startup.ts +64 -20
  74. package/src/codex/pool-refresh-backoff.ts +12 -3
  75. package/src/codex/quota-rejection.ts +104 -15
  76. package/src/codex/routing/cache-affinity.ts +70 -0
  77. package/src/codex/routing/cooldown-math.ts +10 -0
  78. package/src/codex/routing/selection.ts +79 -2
  79. package/src/codex/routing/thread-affinity.ts +50 -2
  80. package/src/codex/routing/transient-hold-dispatch.ts +141 -0
  81. package/src/codex/routing.ts +29 -49
  82. package/src/codex/warmup.ts +1 -1
  83. package/src/combos/failover.ts +85 -0
  84. package/src/combos/request.ts +17 -10
  85. package/src/combos/types.ts +23 -2
  86. package/src/config/atomic-write.ts +83 -8
  87. package/src/config/pending-teardown.ts +31 -0
  88. package/src/config/schema/config-schema.ts +2 -0
  89. package/src/config/schema/leaf-validators.ts +1 -0
  90. package/src/generated/compatibility-version.json +272 -180
  91. package/src/images/loop.ts +1 -1
  92. package/src/lib/bounded-subprocess.ts +62 -10
  93. package/src/lib/errors.ts +17 -0
  94. package/src/lib/request-execution-budget.ts +147 -21
  95. package/src/lib/spend-reservation-ledger.ts +18 -0
  96. package/src/lib/state-store-registrations.ts +6 -2
  97. package/src/lib/test-home-guard.ts +85 -1
  98. package/src/lib/upstream-retry.ts +77 -10
  99. package/src/lib/windows-elevation.ts +76 -14
  100. package/src/lib/windows-secret-acl.ts +151 -15
  101. package/src/lib/windows-user-principal.ts +5 -1
  102. package/src/oauth/index.ts +2 -2
  103. package/src/oauth/key-providers.ts +2 -2
  104. package/src/providers/derive.ts +6 -0
  105. package/src/providers/kiro-models.ts +4 -3
  106. package/src/providers/label.ts +19 -1
  107. package/src/providers/model-discovery.ts +35 -7
  108. package/src/providers/registry/entries-core.ts +18 -0
  109. package/src/providers/registry/entries-extended.ts +59 -28
  110. package/src/providers/registry/model-seeds.ts +71 -17
  111. package/src/providers/registry/types.ts +9 -0
  112. package/src/responses/reasoning-envelope.ts +6 -3
  113. package/src/responses/spill-store.ts +17 -0
  114. package/src/responses/state/body-policy.ts +25 -0
  115. package/src/responses/state/spill-queue.ts +8 -6
  116. package/src/responses/state.ts +3 -22
  117. package/src/router.ts +4 -0
  118. package/src/routing/identity-domains.ts +21 -14
  119. package/src/routing/probe-lease.ts +103 -1
  120. package/src/server/auth-cors.ts +1 -0
  121. package/src/server/chat-completions.ts +3 -1
  122. package/src/server/chat-native.ts +37 -9
  123. package/src/server/index/live-sideband.ts +37 -1
  124. package/src/server/index/websocket-handler.ts +54 -3
  125. package/src/server/index.ts +5 -5
  126. package/src/server/inspection-tee.ts +107 -0
  127. package/src/server/live.ts +46 -1
  128. package/src/server/management/combo-routes.ts +10 -1
  129. package/src/server/management/config-routes.ts +27 -5
  130. package/src/server/models-capabilities.ts +24 -3
  131. package/src/server/relay-eager.ts +2 -0
  132. package/src/server/relay.ts +14 -19
  133. package/src/server/request-log.ts +127 -3
  134. package/src/server/response-log-body.ts +153 -0
  135. package/src/server/responses/account-change-state.ts +74 -0
  136. package/src/server/responses/adapter-continuation.ts +33 -7
  137. package/src/server/responses/adapter-delivery.ts +5 -11
  138. package/src/server/responses/adapter-dispatch.ts +84 -13
  139. package/src/server/responses/codex-ws-exchange.ts +65 -4
  140. package/src/server/responses/codex-ws-wire.ts +5 -0
  141. package/src/server/responses/collaboration.ts +74 -4
  142. package/src/server/responses/combo-session-recall.ts +68 -8
  143. package/src/server/responses/combo-stream-preflight.ts +68 -5
  144. package/src/server/responses/compact.ts +54 -13
  145. package/src/server/responses/core-auth.ts +2 -0
  146. package/src/server/responses/core-codex-account.ts +51 -3
  147. package/src/server/responses/core-combo.ts +129 -23
  148. package/src/server/responses/core-errors.ts +18 -0
  149. package/src/server/responses/core-options.ts +3 -0
  150. package/src/server/responses/core-replay.ts +105 -32
  151. package/src/server/responses/core.ts +3 -3
  152. package/src/server/responses/encrypted-payload.ts +0 -1
  153. package/src/server/responses/fetch-helpers.ts +4 -1
  154. package/src/server/responses/input-admission.ts +126 -6
  155. package/src/server/responses/native-injection-protocol.ts +42 -0
  156. package/src/server/responses/native-injection-replay.ts +105 -0
  157. package/src/server/responses/native-injection.ts +242 -0
  158. package/src/server/responses/native-response-control.ts +56 -0
  159. package/src/server/responses/native-response-json.ts +14 -0
  160. package/src/server/responses/native-response-output.ts +37 -0
  161. package/src/server/responses/native-steering-log.ts +44 -0
  162. package/src/server/responses/native-steering-policy.ts +49 -0
  163. package/src/server/responses/native-steering-replay.ts +126 -0
  164. package/src/server/responses/native-steering-settings.ts +76 -0
  165. package/src/server/responses/native-steering.ts +400 -0
  166. package/src/server/responses/native-tool-results.ts +130 -0
  167. package/src/server/responses/passthrough-delivery.ts +30 -6
  168. package/src/server/responses/passthrough-dispatch.ts +61 -11
  169. package/src/server/responses/passthrough-error.ts +38 -2
  170. package/src/server/responses/request-prepare.ts +173 -22
  171. package/src/server/responses/request-send-budget.ts +97 -2
  172. package/src/server/responses/request-spend.ts +147 -0
  173. package/src/server/responses/request-transport.ts +62 -3
  174. package/src/server/responses/run-turn-execution.ts +59 -31
  175. package/src/server/responses/sidecar-execution.ts +7 -13
  176. package/src/server/responses/terminal-guard.ts +65 -4
  177. package/src/server/responses/ws-upstream.ts +21 -1
  178. package/src/server/responses-undeclared-tool-guard.ts +9 -5
  179. package/src/server/stop-teardown.ts +8 -1
  180. package/src/server/ws-bridge.ts +16 -1
  181. package/src/service/cli.ts +13 -1
  182. package/src/service/windows-ops.ts +210 -16
  183. package/src/service/windows-scheduler.ts +28 -21
  184. package/src/service.ts +1 -1
  185. package/src/types/config.ts +8 -1
  186. package/src/types/provider.ts +13 -0
  187. package/src/types/request.ts +8 -5
  188. package/src/types/tools.ts +24 -0
  189. package/src/types.ts +2 -0
  190. package/src/update/index.ts +10 -0
  191. package/src/update/stop-contract.d.mts +1 -0
  192. package/src/update/stop-contract.mjs +19 -0
  193. package/src/update/stop-decision.d.mts +1 -1
  194. package/src/update/stop-decision.mjs +12 -3
  195. package/src/usage/log.ts +1 -1
  196. package/src/vision/anthropic-describe.ts +1 -1
  197. package/src/vision/describe.ts +5 -5
  198. package/src/web-search/anthropic-executor.ts +1 -1
  199. package/src/web-search/exa-executor.ts +1 -1
  200. package/src/web-search/executor.ts +1 -1
  201. package/src/web-search/gemini-executor.ts +1 -1
  202. package/src/web-search/loop.ts +1 -1
  203. package/src/web-search/ollama-executor.ts +1 -1
  204. package/src/web-search/parse.ts +67 -14
  205. package/src/web-search/passthrough-bridge.ts +64 -31
  206. 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, readCodexAccountRecord } from "./account-store";
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 { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "./catalog/native-models";
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
- if (accountId.startsWith(DIRECT_CALLER_ACCOUNT_PREFIX)) {
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, currentCredentialIdentity(accountId) ?? null] as const
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 = currentCredentialIdentity(accountId);
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
- async release() {
359
- if (released) return;
360
- released = true;
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): number {
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?: number,
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
- function exactResetEligibleCode(
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
- ): CodexResetEligibleExhaustionCode | undefined {
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 RESET_ELIGIBLE_CODES.has(value as CodexResetEligibleExhaustionCode)
230
- ? value as CodexResetEligibleExhaustionCode
231
- : undefined;
281
+ return allowed.has(value) ? value : undefined;
232
282
  }
233
283
 
234
- function structuredResetEligibleCode(payload: unknown): CodexResetEligibleExhaustionCode | undefined {
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 exactResetEligibleCode(root);
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 exactResetEligibleCode(nested as Record<string, unknown>);
294
+ return exactAllowedCode(nested as Record<string, unknown>, allowed);
245
295
  }
246
296
 
247
- async function resetEligibleCodeFromResponse(
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<CodexResetEligibleExhaustionCode | undefined> {
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 undefined;
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 undefined;
258
- return structuredResetEligibleCode(payload);
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 undefined;
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 resetEligibleCodeFromResponse(response, options.signal);
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
+ }