@bitkyc08/opencodex 2.75.0 → 2.76.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 (217) hide show
  1. package/README.md +6 -1
  2. package/bin/ocx.mjs +12 -1
  3. package/gui/dist/assets/App-CddorHdA.js +51 -0
  4. package/gui/dist/assets/App-DMudtISW.css +1 -0
  5. package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
  6. package/gui/dist/assets/index-Btze1I3T.css +1 -0
  7. package/gui/dist/assets/index-CvnY7E6i.js +86 -0
  8. package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
  9. package/gui/dist/index.html +2 -2
  10. package/gui/dist/provider-icons/opengateway.svg +12 -0
  11. package/package.json +1 -1
  12. package/src/adapters/anthropic/account-metadata.ts +17 -0
  13. package/src/adapters/anthropic/client-identity.ts +69 -0
  14. package/src/adapters/anthropic/passthrough.ts +23 -4
  15. package/src/adapters/anthropic.ts +18 -1
  16. package/src/adapters/command-code-project-context.ts +7 -11
  17. package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
  18. package/src/adapters/kiro-retry.ts +23 -8
  19. package/src/adapters/openai-chat/messages.ts +1 -1
  20. package/src/adapters/registry.ts +8 -1
  21. package/src/adapters/zed.ts +377 -0
  22. package/src/bridge/sse.ts +4 -2
  23. package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
  24. package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
  25. package/src/chatgpt/app-server-shim/filter.ts +157 -0
  26. package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
  27. package/src/chatgpt/app-server-shim/launcher.ts +91 -0
  28. package/src/claude/desktop-3p-startup.ts +62 -0
  29. package/src/claude/desktop-3p.ts +5 -0
  30. package/src/claude/first-party-settings.ts +14 -0
  31. package/src/claude/intercept/cli-catalog.ts +150 -0
  32. package/src/claude/intercept/cli-picker.ts +82 -0
  33. package/src/claude/intercept/listener.ts +21 -6
  34. package/src/claude/intercept/picker-bootstrap.ts +23 -5
  35. package/src/claude/intercept/picker-models.ts +57 -13
  36. package/src/claude/intercept/runtime.ts +30 -2
  37. package/src/claude/intercept/settings.ts +3 -0
  38. package/src/cli/agent.ts +172 -0
  39. package/src/cli/alias.ts +2 -1
  40. package/src/cli/capabilities-command.ts +3 -2
  41. package/src/cli/capabilities.ts +87 -0
  42. package/src/cli/chatgpt-command.ts +140 -0
  43. package/src/cli/combo.ts +144 -19
  44. package/src/cli/config-command.ts +3 -1
  45. package/src/cli/dispatch.ts +10 -1
  46. package/src/cli/help.ts +1 -0
  47. package/src/cli/index.ts +5 -24
  48. package/src/cli/init.ts +16 -2
  49. package/src/cli/integrations.ts +13 -0
  50. package/src/cli/internal-command.ts +12 -5
  51. package/src/cli/observe.ts +6 -1
  52. package/src/cli/registry.ts +6 -0
  53. package/src/cli/route-policy.ts +3 -1
  54. package/src/cli/runtime-api.ts +5 -2
  55. package/src/cli/status-oauth.ts +5 -0
  56. package/src/cli/status.ts +8 -1
  57. package/src/cli/stop-approval.ts +55 -16
  58. package/src/cli/stop-report.ts +5 -0
  59. package/src/clients/lazycodex.ts +48 -0
  60. package/src/clients/omo-role-models.ts +111 -0
  61. package/src/codex/account-credit-use.ts +99 -0
  62. package/src/codex/account-lifecycle.ts +2 -0
  63. package/src/codex/agent-role-models.ts +171 -0
  64. package/src/codex/auth-api/account-list.ts +10 -3
  65. package/src/codex/auth-api/routes.ts +34 -0
  66. package/src/codex/auth-context.ts +52 -11
  67. package/src/codex/catalog/discovered-natives.ts +11 -1
  68. package/src/codex/catalog/gather-capture.ts +6 -1
  69. package/src/codex/catalog/model-hints.ts +9 -1
  70. package/src/codex/catalog/provider-models.ts +60 -0
  71. package/src/codex/catalog/routed-gather.ts +30 -5
  72. package/src/codex/convergence-types.ts +2 -1
  73. package/src/codex/main-account-external-usage.ts +62 -0
  74. package/src/codex/main-account-hard-lock.ts +24 -13
  75. package/src/codex/native-profile-processes.ts +53 -16
  76. package/src/codex/prompt-layers/toml-edit.ts +1 -1
  77. package/src/codex/quota-types.ts +3 -7
  78. package/src/codex/quota.ts +19 -4
  79. package/src/codex/role-auto-assign.ts +159 -0
  80. package/src/codex/role-sizing-limits.ts +2 -0
  81. package/src/codex/role-sizing.ts +216 -0
  82. package/src/codex/routing/selection.ts +36 -0
  83. package/src/codex/routing/thread-affinity.ts +2 -0
  84. package/src/codex/routing.ts +4 -4
  85. package/src/codex/subagent-model-fallback.ts +82 -26
  86. package/src/codex/subagent-selectable-models.ts +32 -0
  87. package/src/combos/index.ts +18 -0
  88. package/src/combos/jev-decision-contract.ts +20 -0
  89. package/src/combos/jev-dispatch.ts +18 -0
  90. package/src/combos/jev-model-backend.ts +127 -0
  91. package/src/combos/jev.ts +249 -33
  92. package/src/combos/types.ts +125 -2
  93. package/src/config/diagnostics.ts +18 -0
  94. package/src/config/macos-system-proxy.ts +72 -15
  95. package/src/config/owner-registry.ts +13 -4
  96. package/src/config/process-state.ts +17 -8
  97. package/src/config/proxy-env.ts +35 -5
  98. package/src/config/schema/config-schema.ts +30 -1
  99. package/src/config/schema/leaf-validators.ts +4 -0
  100. package/src/config/serving-runtimes.ts +95 -1
  101. package/src/generated/compatibility-version.json +336 -156
  102. package/src/lib/errors.ts +7 -7
  103. package/src/lib/jsonc.ts +13 -0
  104. package/src/lib/local-chat-completion.ts +144 -0
  105. package/src/lib/provider-outbound.ts +87 -1
  106. package/src/lib/provider-runtime-fetch.ts +23 -0
  107. package/src/lib/provider-tls-profile.ts +221 -0
  108. package/src/lib/request-execution-budget.ts +16 -6
  109. package/src/lib/spend-reservation-ledger.ts +54 -20
  110. package/src/lib/sse-decoder.ts +20 -17
  111. package/src/lib/synced-state-location.ts +96 -0
  112. package/src/lib/winsw.ts +14 -5
  113. package/src/oauth/anthropic-account-refusal.ts +53 -4
  114. package/src/oauth/anthropic-model-quota.ts +83 -0
  115. package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
  116. package/src/oauth/anthropic-routing.ts +112 -58
  117. package/src/oauth/health.ts +39 -3
  118. package/src/oauth/index.ts +14 -0
  119. package/src/oauth/kiro-account-load.ts +10 -0
  120. package/src/oauth/zed.ts +134 -0
  121. package/src/providers/model-discovery.ts +19 -8
  122. package/src/providers/model-rename-fields.ts +1 -0
  123. package/src/providers/provider-id-rewrite.ts +9 -2
  124. package/src/providers/quota/account-cache.ts +29 -7
  125. package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
  126. package/src/providers/quota/anthropic-family-headers.ts +25 -0
  127. package/src/providers/quota/vendor-probes-oauth.ts +5 -1
  128. package/src/providers/quota-types.ts +4 -0
  129. package/src/providers/registry/entries-core.ts +18 -0
  130. package/src/providers/registry/entries-extended.ts +55 -0
  131. package/src/providers/registry/types.ts +3 -0
  132. package/src/providers/xai-fast-model.ts +12 -4
  133. package/src/providers/zed.ts +598 -0
  134. package/src/responses/state/snapshot-select.ts +22 -9
  135. package/src/responses/state.ts +2 -3
  136. package/src/router.ts +9 -0
  137. package/src/server/anthropic-rate-limit-headers.ts +7 -0
  138. package/src/server/auth-cors.ts +7 -0
  139. package/src/server/chat-completions.ts +9 -4
  140. package/src/server/claude-messages.ts +59 -21
  141. package/src/server/index/claude-intercept-lifecycle.ts +71 -25
  142. package/src/server/index/optional-listeners.ts +4 -0
  143. package/src/server/index/spend-ledger-lifecycle.ts +4 -0
  144. package/src/server/management/agent-settings-routes.ts +74 -37
  145. package/src/server/management/claude-desktop-picker-routes.ts +7 -0
  146. package/src/server/management/claude-intercept-routes.ts +35 -0
  147. package/src/server/management/codex-agent-role-routes.ts +122 -0
  148. package/src/server/management/codex-role-auto-assign.ts +208 -0
  149. package/src/server/management/combo-routes.ts +62 -5
  150. package/src/server/management/config-routes.ts +35 -3
  151. package/src/server/management/context.ts +4 -1
  152. package/src/server/management/decision-discovery.ts +74 -0
  153. package/src/server/management/decision-model-validation.ts +80 -0
  154. package/src/server/management/decision-routes.ts +160 -0
  155. package/src/server/management/native-integration-routes.ts +16 -0
  156. package/src/server/management/provider-routes.ts +30 -34
  157. package/src/server/management/route-registry.ts +12 -0
  158. package/src/server/management/sibling-guard.ts +1 -0
  159. package/src/server/management/zed-provider-probe.ts +38 -0
  160. package/src/server/management-api.ts +12 -1
  161. package/src/server/messages-native-oauth.ts +7 -2
  162. package/src/server/messages-native.ts +7 -3
  163. package/src/server/relay.ts +3 -1
  164. package/src/server/request-log.ts +2 -0
  165. package/src/server/responses/adapter-continuation.ts +3 -3
  166. package/src/server/responses/adapter-dispatch.ts +18 -3
  167. package/src/server/responses/compact.ts +14 -1
  168. package/src/server/responses/core-auth.ts +2 -0
  169. package/src/server/responses/core-combo.ts +67 -11
  170. package/src/server/responses/core-normalize.ts +21 -1
  171. package/src/server/responses/core-options.ts +11 -1
  172. package/src/server/responses/core.ts +5 -3
  173. package/src/server/responses/fetch-helpers.ts +6 -3
  174. package/src/server/responses/jev-model-invoke.ts +190 -0
  175. package/src/server/responses/passthrough-delivery.ts +16 -0
  176. package/src/server/responses/policy-fallback.ts +55 -3
  177. package/src/server/responses/policy-request-scope.ts +80 -0
  178. package/src/server/responses/request-prepare.ts +70 -18
  179. package/src/server/responses/request-transport.ts +54 -23
  180. package/src/server/responses/run-turn-execution.ts +1 -0
  181. package/src/server/responses/sidecar-execution.ts +9 -3
  182. package/src/server/responses-hosted-image-display.ts +225 -0
  183. package/src/service/managing-cli.ts +34 -4
  184. package/src/service/systemd-env.ts +75 -0
  185. package/src/service/windows-guarded-manager.ts +60 -19
  186. package/src/service/windows-scheduler.ts +22 -3
  187. package/src/service/windows-taskxml.ts +32 -10
  188. package/src/service-manager-probe.ts +72 -19
  189. package/src/types/config.ts +31 -1
  190. package/src/types/provider.ts +2 -0
  191. package/src/types/request.ts +2 -0
  192. package/src/update/index.ts +39 -6
  193. package/src/update/job.ts +63 -63
  194. package/src/update/npm-cache-preflight.d.mts +33 -1
  195. package/src/update/npm-cache-preflight.mjs +131 -18
  196. package/src/update/ownership-transaction.ts +19 -1
  197. package/src/update/restart-ownership.ts +80 -6
  198. package/src/update/transactional-install.d.mts +2 -0
  199. package/src/update/transactional-install.mjs +6 -1
  200. package/src/usage/jev-stats.ts +28 -1
  201. package/src/usage/log.ts +1 -1
  202. package/src/vision/anthropic-describe.ts +5 -3
  203. package/src/vision/index.ts +1 -0
  204. package/src/vision/plan.ts +2 -1
  205. package/src/vision/routed-describe.ts +29 -121
  206. package/src/web-search/alpha-search.ts +3 -3
  207. package/src/web-search/anthropic-executor.ts +6 -4
  208. package/src/web-search/index.ts +1 -1
  209. package/src/web-search/loop.ts +3 -3
  210. package/src/web-search/passthrough-bridge.ts +2 -1
  211. package/src/web-search/run-turn-loop.ts +1 -1
  212. package/src/web-search/sidecar-providers.ts +2 -1
  213. package/gui/dist/assets/App-ia4U0ufY.css +0 -1
  214. package/gui/dist/assets/App-xm0zGNOe.js +0 -51
  215. package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
  216. package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
  217. package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
@@ -1,3 +1,4 @@
1
+ import { getMainAccountExternalUsageWarning, type MainAccountExternalUsageWarning } from "../main-account-external-usage";
1
2
  import { codexCreditsDtoField, hasCodexCreditsObservation, pruneCodexCredits } from "../credits";
2
3
  import type { CodexCredits } from "../credits";
3
4
  import { getMainChatgptAccountId } from "../auth-collision";
@@ -8,6 +9,7 @@ import type { StoredAccountQuota } from "../quota";
8
9
  import { ConfigMutationLockError, mutatePersistedConfig } from "../../config";
9
10
  import { reconcileMainCodexAccountRuntimeState } from "../account-lifecycle";
10
11
  import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
12
+ import { codexAccountUsesCreditsAfterLimit } from "../account-credit-use";
11
13
  import { getCodexAccountPriority } from "../account-priority";
12
14
  import { getCodexAccountAutoSwitchThresholdOverride } from "../account-auto-switch";
13
15
  import { clearThreadAccountMapForAccount, isCodexAccountPlanExcluded, reconcileCodexActiveAfterExclusion } from "../routing";
@@ -15,7 +17,7 @@ import { codexPlanValue, isThirtyDayOnlyCodexPlan } from "../plan";
15
17
  import { isAccountNeedsReauth, markAccountNeedsReauth } from "../account-runtime-state";
16
18
  import { getValidMainAccountToken, MainAccountTokenRefreshError, MAIN_CODEX_ACCOUNT_ID } from "../main-account";
17
19
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
18
- import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, isMainAccountIdentityGenerationLive } from "../main-account-cache";
20
+ import { captureMainAccountIdentityGeneration, getMainAccountCredentialPresence, getMainAccountInfoCache, getObservedMainQuotaIdentityKey, isMainAccountIdentityGenerationLive } from "../main-account-cache";
19
21
  import type { CodexQuotaRefreshOutcome } from "../quota-refresh-outcome";
20
22
  import { getMainAccountHardLockStatus } from "../main-account-hard-lock";
21
23
  import type { MainAccountHardLockStatus } from "../main-account-hard-lock";
@@ -139,6 +141,7 @@ export function poolAccountDto(
139
141
  paused,
140
142
  priority,
141
143
  autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(config, account.id),
144
+ creditsAfterLimit: codexAccountUsesCreditsAfterLimit(config, account.id),
142
145
  quota: quota ? { ...quota } : null,
143
146
  ...codexCreditsDtoField(config, account.id, poolQuotaHistoryIdentity(account.id) ?? null),
144
147
  needsReauth: needsReauth || health.status === "reauth_required",
@@ -165,6 +168,8 @@ export interface CodexAuthAccountDto {
165
168
  priority: number;
166
169
  /** Null inherits the global usage-switch threshold; 0 disables it for this account. */
167
170
  autoSwitchThresholdOverride: number | null;
171
+ /** False keeps the account out of selection while one of its usage windows is full. */
172
+ creditsAfterLimit?: boolean;
168
173
  quota: (StoredAccountQuota | (Omit<StoredAccountQuota, "updatedAt"> & { updatedAt: number })) | null;
169
174
  credits?: CodexCredits;
170
175
  needsReauth?: boolean;
@@ -183,7 +188,7 @@ export interface CodexAuthAccountDto {
183
188
  healthAction?: string;
184
189
  quotaProbeSkipped?: true;
185
190
  quotaRefresh?: CodexQuotaRefreshOutcome;
186
- mainAccountHardLock?: MainAccountHardLockStatus;
191
+ mainAccountHardLock?: MainAccountHardLockStatus & { externalUsage?: MainAccountExternalUsageWarning };
187
192
  }
188
193
 
189
194
  export interface FreshPoolPlanUpdate {
@@ -371,9 +376,11 @@ export async function listCodexAuthAccountsSnapshot(
371
376
  logLabel: "main",
372
377
  isMain: true,
373
378
  paused: isCodexAccountPaused(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
374
- mainAccountHardLock: getMainAccountHardLockStatus(runtimeConfig),
379
+ mainAccountHardLock: { ...getMainAccountHardLockStatus(runtimeConfig),
380
+ externalUsage: getMainAccountExternalUsageWarning(getObservedMainQuotaIdentityKey()) },
375
381
  priority: getCodexAccountPriority(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
376
382
  autoSwitchThresholdOverride: getCodexAccountAutoSwitchThresholdOverride(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
383
+ creditsAfterLimit: codexAccountUsesCreditsAfterLimit(runtimeConfig, MAIN_CODEX_ACCOUNT_ID),
377
384
  hasCredential: hasMainCredential,
378
385
  needsReauth: mainNeedsReauth,
379
386
  ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
@@ -6,6 +6,7 @@ import { readUsageSnapshotForManagement } from "../../usage/log";
6
6
  import { getAccountQuotaHistory, listAccountQuotas } from "../quota";
7
7
  import { deleteCodexAccount } from "../account-lifecycle";
8
8
  import { isCodexAccountPaused, setCodexAccountPaused } from "../account-pause";
9
+ import { setAllCodexAccountsCreditsAfterLimit, setCodexAccountCreditsAfterLimit } from "../account-credit-use";
9
10
  import { clearCodexAccountPin, isCodexAccountPriorityKey, pinnedCodexAccountId, setCodexAccountPin, setCodexAccountPriority } from "../account-priority";
10
11
  import { codexAccountPinDrainReason, codexQuotaScopeForModel, clearCodexAccountCooldown, clearThreadAccountMapForAccount, getEffectiveActiveCodexAccountId, isEffectiveCodexAccountPinned, resetCodexRoutingForManualSelection } from "../routing";
11
12
  import { DEFAULT_ACCOUNT_PRIORITY, MAX_ACCOUNT_PRIORITY, MIN_ACCOUNT_PRIORITY, normalizeAccountPoolStickyLimit, normalizeCodexAccountPoolStrategy, parseAccountPoolStickyLimit, parseCodexAccountPoolStrategy, parseAccountPriority } from "../pool-rotation";
@@ -114,6 +115,39 @@ export async function handleCodexAuthAPI(
114
115
  });
115
116
  }
116
117
 
118
+ // Pool accounts and `__main__`, like pause. Spending is opt-in: an account not on the list is
119
+ // skipped by the next selection while a usage window is full, and a bound thread is released
120
+ // through the usual block reason, so nothing is cleared here. `{ all }` is the dashboard's
121
+ // global switch: on lists every current account, off clears the list.
122
+ if (url.pathname === "/api/codex-auth/accounts/credits" && req.method === "PUT") {
123
+ const parsed: unknown = await req.json().catch(() => null);
124
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
125
+ return jsonResponse({ error: "body must be an object" }, 400);
126
+ }
127
+ const body = parsed as { id?: unknown; creditsAfterLimit?: unknown; all?: unknown };
128
+ const runtimeConfig = getRuntimeConfig(config);
129
+ if (body.all !== undefined) {
130
+ if (typeof body.all !== "boolean") return jsonResponse({ error: "all must be a boolean" }, 400);
131
+ const ids = [MAIN_CODEX_ACCOUNT_ID, ...(runtimeConfig.codexAccounts ?? [])
132
+ .filter(isSelectableCodexPoolAccount)
133
+ .map(account => account.id)];
134
+ setAllCodexAccountsCreditsAfterLimit(runtimeConfig, ids, body.all);
135
+ saveRuntimeConfig(config, runtimeConfig);
136
+ return jsonResponse({ ok: true, all: body.all, ids: runtimeConfig.creditCodexAccountIds ?? [] });
137
+ }
138
+ const id = typeof body.id === "string" ? body.id.trim() : "";
139
+ if (id !== MAIN_CODEX_ACCOUNT_ID && !isValidCodexAccountId(id)) return jsonResponse({ error: "Invalid account id format" }, 400);
140
+ if (typeof body.creditsAfterLimit !== "boolean") return jsonResponse({ error: "creditsAfterLimit must be a boolean" }, 400);
141
+
142
+ const exists = id === MAIN_CODEX_ACCOUNT_ID
143
+ || (runtimeConfig.codexAccounts ?? []).some(account => isSelectableCodexPoolAccount(account) && account.id === id);
144
+ if (!exists) return jsonResponse({ error: "Account not found" }, 404);
145
+
146
+ setCodexAccountCreditsAfterLimit(runtimeConfig, id, body.creditsAfterLimit);
147
+ saveRuntimeConfig(config, runtimeConfig);
148
+ return jsonResponse({ ok: true, id, creditsAfterLimit: body.creditsAfterLimit });
149
+ }
150
+
117
151
  // Deliberately a route of its own rather than a field on the alias PATCH: aliases
118
152
  // are display-only and reject __main__, while selection order is routing metadata
119
153
  // that the Desktop account must be able to carry. Re-ordering never kicks a live
@@ -1,3 +1,4 @@
1
+ import { noteMainAccountActivity } from "./main-account-external-usage";
1
2
  import { codexAccountPriorityFailbackEnabled } from "./account-priority";
2
3
  import type { PoolQuotaWriter } from "./quota-types";
3
4
  import { createHash, timingSafeEqual } from "node:crypto";
@@ -63,13 +64,14 @@ import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS, NATIVE_RESERVE_MODEL } from "./cata
63
64
  import type { CodexCooldownSource, CodexQuotaScope } from "./routing";
64
65
  import { maskAccountId } from "../lib/privacy";
65
66
  import { formatErrorResponse } from "../bridge";
66
- import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota";
67
+ import { CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, getMainPolicyQuota, parseUsageQuota, parseMainPolicyUsageQuota, setAccountQuotaFromParsed } from "./quota";
68
+ import { codexAccountUsesCreditsAfterLimit, codexUsageLimitResetAt } from "./account-credit-use";
67
69
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
68
70
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
69
71
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
70
72
  import { extractAccountId, extractEmail } from "../oauth/chatgpt";
71
73
  import {
72
- MAIN_ACCOUNT_HARD_LOCK_PERCENT,
74
+ resolveMainAccountHardLockThresholds,
73
75
  getMainAccountHardLockStatus,
74
76
  isMainAccountHardLockEnabled,
75
77
  isMainAccountHardLocked,
@@ -117,7 +119,7 @@ function requestOwnedMainPinHasQuotaHeadroom(config: OcxConfig): boolean {
117
119
  *
118
120
  * Read-free by construction, which is what makes it usable on the fenced side. Every input is
119
121
  * config, policy, or in-memory runtime state: the pin fields, the paused list, the cached quota
120
- * score, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
122
+ * score, the main policy quota the hard lock and the credits switch read, and `callerMatchesObservedMain`, which compares HMAC digests against the observed
121
123
  * credential record in `main-account-cache.ts`. Nothing here opens a file.
122
124
  *
123
125
  * `candidate` is the pin before the hard-lock question, because the caller still owes the
@@ -141,6 +143,7 @@ export function requestOwnedMainPinState(
141
143
  candidate,
142
144
  preserve: candidate && !(callerMatchesObservedMain(headers)
143
145
  && (isMainAccountHardLocked(policy)
146
+ || mainCreditsHoldResetAt(policy) !== undefined
144
147
  || getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope)?.cooldownUntil)),
145
148
  };
146
149
  }
@@ -510,15 +513,28 @@ export class CodexAccountCooldownError extends Error {
510
513
  export class CodexMainAccountHardLockError extends CodexAccountCooldownError {
511
514
  readonly resetAt?: number;
512
515
 
513
- constructor(resetAt?: number) {
516
+ constructor(resetAt?: number, thresholds = resolveMainAccountHardLockThresholds(undefined)) {
514
517
  super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
515
518
  this.name = "CodexMainAccountHardLockError";
516
519
  this.resetAt = resetAt;
517
- this.message = `Codex main account is blocked by the ${MAIN_ACCOUNT_HARD_LOCK_PERCENT}% main-account quota policy.`
520
+ this.message = `Codex main account is blocked by the main-account quota policy (5h ≥ ${thresholds.short}%, weekly ≥ ${thresholds.long}%).`
518
521
  + " Choose another account, wait for quota to reset, or disable codexMainAccountHardLock in Settings.";
519
522
  }
520
523
  }
521
524
 
525
+ /** The main login may not spend credits and one of its usage windows is full (#6334). */
526
+ export class CodexMainAccountCreditsOffError extends CodexAccountCooldownError {
527
+ readonly resetAt?: number;
528
+
529
+ constructor(resetAt?: number) {
530
+ super(MAIN_CODEX_ACCOUNT_ID, resetAt ?? 0);
531
+ this.name = "CodexMainAccountCreditsOffError";
532
+ this.resetAt = resetAt;
533
+ this.message = "Codex main account reached its usage limit, and spending ChatGPT credits is off for it."
534
+ + " Choose another account, wait for the limit to reset, or allow the main account under \"Use credits\" in Codex Auth.";
535
+ }
536
+ }
537
+
522
538
  export class CodexReserveUnavailableError extends CodexAccountCooldownError {
523
539
  constructor() {
524
540
  super(MAIN_CODEX_ACCOUNT_ID, 0);
@@ -577,7 +593,8 @@ export class CodexRecoveryWithheldError extends CodexAccountCooldownError {
577
593
  }
578
594
 
579
595
  export type CodexAuthPolicyConfig = Readonly<Pick<OcxConfig,
580
- "codexMainAccountHardLock" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
596
+ "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "codexDesktopAuthless" | "runtimeRole" | "pausedCodexAccountIds"
597
+ | "creditCodexAccountIds"
581
598
  >>;
582
599
 
583
600
  interface CodexAuthMaterializationOptions {
@@ -700,10 +717,28 @@ export function unwrapUpstreamRetryEvidenceError(error: unknown): unknown {
700
717
  return error;
701
718
  }
702
719
 
703
- function assertMainAccountPolicy(config: Pick<OcxConfig, "codexMainAccountHardLock"> | undefined): void {
704
- if (!config) return;
705
- const status = getMainAccountHardLockStatus(config);
706
- if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt);
720
+ /**
721
+ * When the main login's full window ends, if credits are off for it and a window is full (#6334).
722
+ * Same evidence the hard lock reads, and no plan lookup: the plan can live in the physical auth
723
+ * file, which several callers are forbidden to open, so every long window counts instead.
724
+ */
725
+ function mainCreditsHoldResetAt(config: Pick<OcxConfig, "creditCodexAccountIds">): number | undefined {
726
+ if (codexAccountUsesCreditsAfterLimit(config, MAIN_CODEX_ACCOUNT_ID)) return undefined;
727
+ return codexUsageLimitResetAt(getMainPolicyQuota(), undefined, Date.now());
728
+ }
729
+
730
+ function assertMainAccountPolicy(
731
+ config: Pick<OcxConfig, "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds" | "creditCodexAccountIds"> | undefined,
732
+ ): void {
733
+ if (config) {
734
+ const status = getMainAccountHardLockStatus(config);
735
+ if (status.state === "blocked") throw new CodexMainAccountHardLockError(status.resetAt, status.thresholds);
736
+ const creditsResetAt = mainCreditsHoldResetAt(config);
737
+ if (creditsResetAt !== undefined) throw new CodexMainAccountCreditsOffError(creditsResetAt);
738
+ }
739
+ // Only an admitted request is opencodex's own use of the main account. Counting a refused one
740
+ // would hide outside usage from the warning exactly while the lock is holding.
741
+ noteMainAccountActivity();
707
742
  }
708
743
 
709
744
  /** No auth-file I/O: an unsigned claim alone never identifies a caller as stored main. */
@@ -826,6 +861,9 @@ export function cooldownAccountLabel(accountId: string): string {
826
861
  */
827
862
  export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSelector?: string): string {
828
863
  if (err instanceof CodexMainAccountHardLockError
864
+ // A credits-off refusal is a configuration policy, not a cooldown: clearing a cooldown
865
+ // cannot lift it, so its own wording (wait for the reset or allow credits) is the remedy.
866
+ || err instanceof CodexMainAccountCreditsOffError
829
867
  || err instanceof CodexReserveUnavailableError
830
868
  // A transient-hold refusal is not a quota cooldown. Its own wording is the only accurate
831
869
  // one, and the quota recovery advice below would send the operator after a cooldown that
@@ -874,6 +912,7 @@ export class CodexThreadAffinityExpiredError extends Error {
874
912
 
875
913
  export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown): boolean {
876
914
  return !(cause instanceof CodexMainAccountHardLockError)
915
+ && !(cause instanceof CodexMainAccountCreditsOffError)
877
916
  && !(cause instanceof CodexAccountValidationPendingError)
878
917
  && !(cause instanceof CodexReserveUnavailableError)
879
918
  && !(cause instanceof CodexCredentialGenerationConflictError)
@@ -1428,7 +1467,9 @@ export async function resolveCodexAuthContext(
1428
1467
  releaseTransientProbeGrant();
1429
1468
  if (probeLeaseId && probeQuotaScope) releaseCodexQuotaScopeProbeLease(accountId, probeQuotaScope, probeLeaseId);
1430
1469
  else if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
1431
- if (cause instanceof CodexMainAccountHardLockError) throw cause;
1470
+ // Policy refusals, including one that lands while the token refresh was in flight, are not
1471
+ // authentication failures: they must stay reset-bound 429s and never mark a valid login.
1472
+ if (cause instanceof CodexMainAccountHardLockError || cause instanceof CodexMainAccountCreditsOffError) throw cause;
1432
1473
  if (!options.signal?.aborted && shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
1433
1474
  markAccountNeedsReauth(accountId, writerGeneration);
1434
1475
  }
@@ -21,6 +21,7 @@ export const DISCOVERED_NATIVE_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
21
21
  */
22
22
  export const DISCOVERED_NATIVE_RENEW_INTERVAL_MS = 60 * 60 * 1000;
23
23
  const FILE_NAME = "discovered-native-models.json";
24
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
24
25
 
25
26
  export interface DiscoveredNativeModel {
26
27
  slug: string;
@@ -35,6 +36,13 @@ let models: DiscoveredNativeModel[] = [];
35
36
  let fingerprint = "[]";
36
37
  let generation = 0;
37
38
 
39
+ function containsLoneSurrogate(value: unknown): boolean {
40
+ if (typeof value === "string") return LONE_SURROGATE.test(value);
41
+ if (Array.isArray(value)) return value.some(containsLoneSurrogate);
42
+ if (!value || typeof value !== "object") return false;
43
+ return Object.entries(value).some(([key, child]) => LONE_SURROGATE.test(key) || containsLoneSurrogate(child));
44
+ }
45
+
38
46
  /** Validate untrusted roster rows without filesystem effects; keep upstream capability metadata. */
39
47
  export function validateDiscoveredNativeRows(rows: unknown): Record<string, unknown>[] {
40
48
  if (!Array.isArray(rows)) return [];
@@ -56,8 +64,10 @@ export function validateDiscoveredNativeRows(rows: unknown): Record<string, unkn
56
64
  try {
57
65
  const serialized = JSON.stringify(row);
58
66
  if (Buffer.byteLength(serialized, "utf8") > DISCOVERED_NATIVE_MAX_ROW_BYTES) continue;
67
+ const cloned = JSON.parse(serialized) as Record<string, unknown>;
68
+ if (containsLoneSurrogate(cloned)) continue;
59
69
  if (accepted.size >= DISCOVERED_NATIVE_MAX_ROWS && !accepted.has(row.slug)) continue;
60
- accepted.set(row.slug, JSON.parse(serialized) as Record<string, unknown>);
70
+ accepted.set(row.slug, cloned);
61
71
  } catch { /* Cyclic/non-JSON rows cannot be persisted or sent to Codex. */ }
62
72
  }
63
73
  return [...accepted.values()];
@@ -121,6 +121,8 @@ export interface ModelsAuthResolution {
121
121
  readonly apiKey: string | undefined;
122
122
  readonly observed: boolean;
123
123
  readonly oauthAccountId?: string;
124
+ /** Upstream user id carried by the OAuth snapshot (Zed); distinct from the store slot id. */
125
+ readonly oauthProviderUserId?: string;
124
126
  readonly oauthGeneration?: string;
125
127
  readonly oauthApiBaseUrl?: string;
126
128
  readonly oauthProjectId?: string;
@@ -417,6 +419,7 @@ export function captureProviderGather(
417
419
  finalMethod: request.method,
418
420
  finalUrl: request.url,
419
421
  filter: capturedField(discovery.spec, "filter"),
422
+ preferFirst: capturedField(discovery.spec, "preferFirst"),
420
423
  maxResponseBytes: discovery.maxResponseBytes,
421
424
  maxModels: discovery.maxModels,
422
425
  trustedOpenAiApi,
@@ -446,7 +449,9 @@ export function captureGatherFlight(
446
449
  const authResolver = createAuthResolver(providerAuthOutcomes);
447
450
  const comboTargetsByProvider = configuredComboTargetModelsByProvider(config);
448
451
  const providers = Object.entries(config.providers)
449
- .filter(([, provider]) => provider.disabled !== true)
452
+ // A decision service (canonical TypeSafe or a self-hosted `jev-decision` row) owns no model
453
+ // transport: never probe its endpoint for /models and never publish its model id as routable.
454
+ .filter(([, provider]) => provider.disabled !== true && provider.adapter !== "jev-decision")
450
455
  .map(([name, provider]) => captureProviderGather(
451
456
  name,
452
457
  provider,
@@ -604,7 +604,12 @@ export function modelInputModalities(
604
604
  ))) {
605
605
  return ["text", "image"];
606
606
  }
607
- return undefined;
607
+ // OpenGateway publishes modalities as { input: [...], output: [...] }, not an array.
608
+ // Append this fallback after all recognized signals so existing providers keep their
609
+ // resolution, and retain Codex's closed enum rather than advertising output modalities.
610
+ const nestedInput = normalizedStringList(plainRecord(item.modalities)?.input, 8, 24)
611
+ ?.filter(value => value === "text" || value === "image" || value === "audio");
612
+ return nestedInput && nestedInput.length > 0 ? nestedInput : undefined;
608
613
  }
609
614
 
610
615
  /**
@@ -688,6 +693,9 @@ export function catalogHintsFromModelsApiItem(providerName: string, item: Provid
688
693
  // real values. Appended after the recognized fields for the same reason as the
689
694
  // llama.cpp entries above: no provider that already resolves changes behavior.
690
695
  capabilityRecord?.context_length,
696
+ // OpenGateway publishes a top-level context_window. Keep this last so catalogs
697
+ // already resolving through any recognized field retain their existing window.
698
+ item.context_window,
691
699
  );
692
700
  const maxInputTokens = positiveSafeInteger(limits?.max_input_tokens, item.max_input_tokens);
693
701
  const maxOutputTokens = positiveSafeInteger(
@@ -62,6 +62,7 @@ import { fetchCodeBuddyModels } from "../../adapters/codebuddy/live-models";
62
62
  import { resolveProfileByBaseUrl } from "../../adapters/coding-agent/profile";
63
63
  import { fetchDevinUsableModels } from "../../adapters/devin/live-models";
64
64
  import { resolveDevinApiBaseUrl } from "../../oauth/devin/api-base";
65
+ import { resolveZedModels } from "../../providers/zed";
65
66
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
66
67
  import {
67
68
  COMBO_NAMESPACE,
@@ -152,6 +153,7 @@ export function observedModelsAuthResolver(
152
153
  observed: true,
153
154
  oauthAccountId: observation.snapshot.accountId,
154
155
  oauthGeneration: observation.snapshot.generation,
156
+ ...(observation.snapshot.providerUserId ? { oauthProviderUserId: observation.snapshot.providerUserId } : {}),
155
157
  ...(observation.snapshot.apiBaseUrl ? { oauthApiBaseUrl: observation.snapshot.apiBaseUrl } : {}),
156
158
  ...(observation.snapshot.projectId ? { oauthProjectId: observation.snapshot.projectId } : {}),
157
159
  };
@@ -263,6 +265,7 @@ export async function fetchProviderModelsWithAuth(
263
265
  observed: false,
264
266
  oauthAccountId: snapshot.accountId,
265
267
  oauthGeneration: snapshot.generation,
268
+ ...(snapshot.providerUserId ? { oauthProviderUserId: snapshot.providerUserId } : {}),
266
269
  ...(snapshot.apiBaseUrl ? { oauthApiBaseUrl: snapshot.apiBaseUrl } : {}),
267
270
  ...(snapshot.projectId ? { oauthProjectId: snapshot.projectId } : {}),
268
271
  }))
@@ -342,6 +345,63 @@ export async function fetchProviderModelsWithAuth(
342
345
  stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured,
343
346
  ), "degraded");
344
347
  }
348
+ if (name === "zed" && prov.adapter === "zed") {
349
+ if (!apiKey || !auth.oauthAccountId || !auth.oauthProviderUserId) return observed(configured, "degraded");
350
+ // Zed's roster and short-lived inference token are both account-scoped. Keep the
351
+ // catalog cache bound to the same pair so a multi-account switch cannot reuse a stale
352
+ // roster even when the provider destination is unchanged.
353
+ const authorityIdentity = createHash("sha256")
354
+ .update(JSON.stringify([auth.oauthAccountId, apiKey])).digest("hex");
355
+ const cached = getFreshCached(name, ttlMs, Date.now(), authorityIdentity);
356
+ if (cached) {
357
+ return observed(
358
+ withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, cached, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)),
359
+ "authoritative",
360
+ );
361
+ }
362
+ const stale = getStaleCached(name, authorityIdentity);
363
+ if (isModelsFetchCoolingDown(name, undefined, undefined, authorityIdentity) && stale) {
364
+ return observed(
365
+ withConfiguredRetention(applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias)),
366
+ "degraded",
367
+ );
368
+ }
369
+ const zedFetch = (prov as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch;
370
+ try {
371
+ const live = await resolveZedModels(
372
+ // Zed signs with its own user id; the slot id only keys the catalog cache above.
373
+ { userId: auth.oauthProviderUserId, accessToken: apiKey },
374
+ { signal: AbortSignal.timeout(8_000), ...(zedFetch ? { fetchFn: zedFetch } : {}) },
375
+ );
376
+ const discovered = live.models.map(model => {
377
+ const reasoningEfforts = sanitizeCodexReasoningEfforts(model.supportedEffortLevels);
378
+ return {
379
+ id: model.id,
380
+ provider: name,
381
+ ...(model.contextLength ? { contextWindow: model.contextLength } : {}),
382
+ ...(model.maxOutputTokens ? { maxOutputTokens: model.maxOutputTokens } : {}),
383
+ ...(model.supportsImages ? { inputModalities: ["text", "image"] } : {}),
384
+ ...(reasoningEfforts?.length ? { reasoningEfforts } : {}),
385
+ ...catalogHintsFromProviderConfig(name, prov, model.id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
386
+ } as CatalogModel;
387
+ });
388
+ const forCache = withConfiguredRetention(discovered, { retainComboTargets: false });
389
+ if (!setCached(name, forCache, Date.now(), cacheGeneration, authorityIdentity)) {
390
+ return observed(withConfiguredRetention(configured), "degraded");
391
+ }
392
+ markProviderDiscoveryOk(name, live.models.length);
393
+ return observed(withConfiguredRetention(forCache, { warnDrops: true }), "authoritative");
394
+ } catch {
395
+ if (isCurrentCacheGeneration()) {
396
+ markModelsFetchFailure(name, undefined, authorityIdentity);
397
+ markProviderDiscoveryFailed(name, { reason: "provider" });
398
+ }
399
+ return observed(
400
+ withConfiguredRetention(stale ? applyConfigHintsToCachedModels(name, prov, stale, contextCap, metadataModelIdCaseFold, captured.effectiveAlias) : configured),
401
+ "degraded",
402
+ );
403
+ }
404
+ }
345
405
  if (prov.adapter === "qoder") {
346
406
  if (!apiKey) return observed(configured, "degraded");
347
407
  const profile = resolveQoderProfile(prov.baseUrl);
@@ -528,7 +528,16 @@ async function gatherRoutedModelsUncached(
528
528
  else warnUncataloguedComboOnce(id, combo, members, localOmissions);
529
529
  }
530
530
  replaceLastComboCatalogOmissions(localOmissions);
531
- all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
531
+ // Preserve declaratively ordered discovery (and cold-start seed order); other presets
532
+ // retain their historical alphabetical order. Renamed/custom transport scoping still applies.
533
+ // Read the captured discovery policy, never the live registry: a flight must keep the
534
+ // authority it was captured under even if the registry changes mid-gather.
535
+ const orderedProviders = new Set(activeProviders
536
+ .filter(({ discovery }) => (discovery.spec?.preferFirst?.length ?? 0) > 0)
537
+ .map(({ name }) => name));
538
+ all.sort((a, b) => (a.provider === b.provider
539
+ ? orderedProviders.has(a.provider) ? 0 : a.id.localeCompare(b.id)
540
+ : a.provider.localeCompare(b.provider)));
532
541
  // Provider-derived rows keyed by their Codex-facing slug: a custom override replaces the row
533
542
  // with the same slug below, so that row's provider capability metadata is the inheritance source.
534
543
  const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model]));
@@ -737,10 +746,26 @@ async function gatherRoutedModelsUncached(
737
746
  }
738
747
  return mergedWithAutoCompact;
739
748
  });
740
- // Custom rows override discovered rows that encode to the same Codex-facing slug.
741
- const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
742
- const deduped = all.filter(m => !customKeys.has(routedSlug(m.provider, m.id)));
743
- const models = [...deduped, ...customModels];
749
+ // Custom rows override discovered rows that encode to the same Codex-facing slug. A provider whose
750
+ // captured discovery declares an order (preferFirst) keeps each replacement in the discovered row's
751
+ // slot; every other provider keeps the historical order of discovered rows followed by custom rows.
752
+ const customBySlug = new Map<string, CatalogModel[]>();
753
+ for (const custom of customModels) {
754
+ const key = routedSlug(custom.provider, custom.id);
755
+ const bucket = customBySlug.get(key);
756
+ if (bucket) bucket.push(custom);
757
+ else customBySlug.set(key, [custom]);
758
+ }
759
+ const placedInSlot = new Set<string>();
760
+ const models: CatalogModel[] = all.flatMap(model => {
761
+ const key = routedSlug(model.provider, model.id);
762
+ const replacements = customBySlug.get(key);
763
+ if (!replacements) return [model];
764
+ if (!orderedProviders.has(model.provider) || placedInSlot.has(key)) return [];
765
+ placedInSlot.add(key);
766
+ return replacements;
767
+ });
768
+ models.push(...customModels.filter(custom => !placedInSlot.has(routedSlug(custom.provider, custom.id))));
744
769
  // ponytail: catalog-scale scan; index ids by provider if catalog growth makes this measurable.
745
770
  const aliasDisplayNames = new Map(activeProviders.flatMap(({ name, provider }) => {
746
771
  const providerModels = models.filter(model => model.provider === name);
@@ -14,7 +14,7 @@
14
14
  * behavior at its own commit, which a runtime placeholder here would break.
15
15
  */
16
16
  import type { OcxConfig } from "../types";
17
- import type { ProviderModelDiscoveryFilter } from "../providers/registry";
17
+ import type { ProviderModelDiscoveryFilter, ProviderModelDiscoveryPredicate } from "../providers/registry";
18
18
 
19
19
  /**
20
20
  * The non-CAS JSON record for the Codex integration.
@@ -448,6 +448,7 @@ export interface CatalogProviderDiscoveryPolicySnapshot {
448
448
  readonly finalMethod: "GET" | "POST";
449
449
  readonly finalUrl: string;
450
450
  readonly filter: CatalogDiscoveryPolicyField<ProviderModelDiscoveryFilter | undefined>;
451
+ readonly preferFirst: CatalogDiscoveryPolicyField<readonly ProviderModelDiscoveryPredicate[] | undefined>;
451
452
  readonly maxResponseBytes: number;
452
453
  readonly maxModels: number;
453
454
  readonly trustedOpenAiApi: CatalogTrustedOpenAiApiPolicySnapshot;
@@ -0,0 +1,62 @@
1
+ /** Advisory fresh-observation detector. No persistence, timers, or merged quota reads. */
2
+ export const EXTERNAL_USAGE_QUIET_MS = 30 * 60_000;
3
+ export const EXTERNAL_USAGE_TTL_MS = 6 * 60 * 60_000;
4
+ export type FreshWindow = { kind: "short" | "long"; percent: number; resetAtMs: number };
5
+ export type MainAccountExternalUsageWarning = {
6
+ window: "short" | "long";
7
+ fromPercent: number;
8
+ toPercent: number;
9
+ observedAt: number;
10
+ };
11
+ type Reading = { percent: number; resetAtMs: number; observedAt: number;
12
+ warning?: MainAccountExternalUsageWarning };
13
+ let baseline: { identityKey: string; perWindow: Partial<Record<FreshWindow["kind"], Reading>> } | undefined;
14
+ let lastActivity = -Infinity;
15
+
16
+ export function noteMainAccountActivity(now = Date.now()): void {
17
+ lastActivity = now;
18
+ }
19
+
20
+ /** Only validated usage with a known reset from this observation reaches the baseline. */
21
+ export function observeMainAccountUsage(identityKey: string, windows: FreshWindow[], now = Date.now()): void {
22
+ if (baseline?.identityKey !== identityKey) baseline = { identityKey, perWindow: {} };
23
+ for (const window of windows) {
24
+ if (!Number.isFinite(window.percent) || window.percent < 0 || window.percent > 100
25
+ || !Number.isFinite(window.resetAtMs) || window.resetAtMs <= 0) continue;
26
+ const previous = baseline.perWindow[window.kind];
27
+ const sameEpisode = previous?.resetAtMs === window.resetAtMs;
28
+ let warning = sameEpisode ? previous?.warning : undefined;
29
+ if (sameEpisode && previous && window.percent - previous.percent >= 1
30
+ && lastActivity < previous.observedAt - EXTERNAL_USAGE_QUIET_MS) {
31
+ warning = { window: window.kind, fromPercent: previous.percent,
32
+ toPercent: window.percent, observedAt: now };
33
+ }
34
+ baseline.perWindow[window.kind] = { percent: window.percent, resetAtMs: window.resetAtMs,
35
+ observedAt: now, ...(warning ? { warning } : {}) };
36
+ }
37
+ }
38
+
39
+ export function forgetMainAccountUsage(): void {
40
+ baseline = undefined;
41
+ }
42
+
43
+ export function getMainAccountExternalUsageWarning(
44
+ identityKey: string | undefined, now = Date.now(),
45
+ ): MainAccountExternalUsageWarning | undefined {
46
+ if (!identityKey || baseline?.identityKey !== identityKey) return undefined;
47
+ for (const kind of ["short", "long"] as const) {
48
+ const reading = baseline.perWindow[kind];
49
+ if (!reading?.warning) continue;
50
+ if (now >= reading.resetAtMs || now - reading.warning.observedAt >= EXTERNAL_USAGE_TTL_MS) {
51
+ delete reading.warning;
52
+ continue;
53
+ }
54
+ return { ...reading.warning };
55
+ }
56
+ return undefined;
57
+ }
58
+
59
+ export function resetMainAccountExternalUsageForTests(): void {
60
+ forgetMainAccountUsage();
61
+ lastActivity = -Infinity;
62
+ }
@@ -1,6 +1,6 @@
1
1
  import type { OcxConfig } from "../types";
2
2
  import { getMainPolicyQuota } from "./quota";
3
- import { MAIN_ACCOUNT_HARD_LOCK_PERCENT } from "./quota-types";
3
+ import { MAIN_ACCOUNT_HARD_LOCK_PERCENT, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "./quota-types";
4
4
 
5
5
  export { MAIN_ACCOUNT_HARD_LOCK_PERCENT };
6
6
 
@@ -9,9 +9,19 @@ export interface MainAccountHardLockStatus {
9
9
  state: "off" | "unknown" | "ready" | "blocked";
10
10
  /** Unix milliseconds; absent when a blocking observation has no future reset. */
11
11
  resetAt?: number;
12
+ thresholds: { short: number; long: number };
13
+ window?: "short" | "long";
12
14
  }
13
15
 
14
- type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock">;
16
+ type PolicyConfig = Pick<OcxConfig, "codexMainAccountHardLock" | "codexMainAccountHardLockThresholds">;
17
+
18
+ export function resolveMainAccountHardLockThresholds(config: PolicyConfig | undefined): { short: number; long: number } {
19
+ const valid = (value: unknown, fallback: number): number => typeof value === "number"
20
+ && Number.isInteger(value) && value >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT && value <= 100 ? value : fallback;
21
+ const long = valid(config?.codexMainAccountHardLockThresholds?.long, MAIN_ACCOUNT_HARD_LOCK_PERCENT);
22
+ const short = valid(config?.codexMainAccountHardLockThresholds?.short, MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT);
23
+ return { short: Math.min(short, long), long };
24
+ }
15
25
 
16
26
  /**
17
27
  * Whether the main-account hard lock applies to this config (#5694).
@@ -31,11 +41,11 @@ function resetTimestamp(value: number | undefined): number | undefined {
31
41
  return value < 10_000_000_000 ? value * 1000 : value;
32
42
  }
33
43
 
34
- type WindowReading = { percent: number | undefined; resetAt: number | undefined };
44
+ type WindowReading = { kind: "short" | "long"; percent: number | undefined; resetAt: number | undefined };
35
45
 
36
46
  /**
37
47
  * The windows that govern the lock. The 5h and weekly windows each govern on their own, so either
38
- * one at 98% blocks even while the other has headroom. Monthly governs only a monthly-only account:
48
+ * one at its configured threshold blocks even while the other has headroom. Monthly governs only a monthly-only account:
39
49
  * supplementary monthly data on a 5h/weekly account never becomes a lock. The list is never empty,
40
50
  * so a record with no readings at all classifies as unknown rather than vacuously ready.
41
51
  */
@@ -44,9 +54,9 @@ function governingWindows(quota: NonNullable<ReturnType<typeof getMainPolicyQuot
44
54
  || quota.shortWindowSeconds !== undefined;
45
55
  const hasWeekly = quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
46
56
  const windows: WindowReading[] = [];
47
- if (hasShort) windows.push({ percent: quota.shortPercent, resetAt: quota.shortResetAt });
48
- if (hasWeekly) windows.push({ percent: quota.weeklyPercent, resetAt: quota.weeklyResetAt });
49
- if (windows.length === 0) windows.push({ percent: quota.monthlyPercent, resetAt: quota.monthlyResetAt });
57
+ if (hasShort) windows.push({ kind: "short", percent: quota.shortPercent, resetAt: quota.shortResetAt });
58
+ if (hasWeekly) windows.push({ kind: "long", percent: quota.weeklyPercent, resetAt: quota.weeklyResetAt });
59
+ if (windows.length === 0) windows.push({ kind: "long", percent: quota.monthlyPercent, resetAt: quota.monthlyResetAt });
50
60
  return windows;
51
61
  }
52
62
 
@@ -60,22 +70,23 @@ export function getMainAccountHardLockStatus(
60
70
  config: PolicyConfig,
61
71
  now = Date.now(),
62
72
  ): MainAccountHardLockStatus {
63
- if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off" };
73
+ const thresholds = resolveMainAccountHardLockThresholds(config);
74
+ if (!isMainAccountHardLockEnabled(config)) return { enabled: false, state: "off", thresholds };
64
75
  const quota = getMainPolicyQuota();
65
- if (!quota) return { enabled: true, state: "unknown" };
76
+ if (!quota) return { enabled: true, state: "unknown", thresholds };
66
77
  const windows = governingWindows(quota);
67
- const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= MAIN_ACCOUNT_HARD_LOCK_PERCENT);
78
+ const blocking = windows.filter(w => validPercent(w.percent) && w.percent >= thresholds[w.kind]);
68
79
  if (blocking.length > 0) {
69
80
  // The lock holds until every blocking window reads lower or is authoritatively absent, so the earliest possible unlock is
70
81
  // the latest blocking reset. One blocking window without a future reset makes it unknowable.
71
82
  // A predicted reset is not evidence of recovery; fresh lower usage or validated WHAM absence releases.
72
83
  const resets = blocking.map(w => resetTimestamp(w.resetAt));
73
84
  const resetAt = resets.every(r => r !== undefined && r > now) ? Math.max(...(resets as number[])) : undefined;
74
- return { enabled: true, state: "blocked", ...(resetAt !== undefined ? { resetAt } : {}) };
85
+ return { enabled: true, state: "blocked", thresholds, window: blocking[0]!.kind, ...(resetAt !== undefined ? { resetAt } : {}) };
75
86
  }
76
87
  // Unknown admits, so an unreadable window never hides a blocking one and never blocks alone.
77
- if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown" };
78
- return { enabled: true, state: "ready" };
88
+ if (windows.some(w => !validPercent(w.percent))) return { enabled: true, state: "unknown", thresholds };
89
+ return { enabled: true, state: "ready", thresholds };
79
90
  }
80
91
 
81
92
  export function isMainAccountHardLocked(config: PolicyConfig, now = Date.now()): boolean {