@bitkyc08/opencodex 2.88.0 → 2.89.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 (205) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-BnRSujOq.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
  7. package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/coding-agent/protocol.ts +6 -0
  14. package/src/adapters/google-http.ts +3 -2
  15. package/src/adapters/google-vertex-endpoint.ts +67 -0
  16. package/src/adapters/google.ts +35 -29
  17. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  18. package/src/adapters/openai-responses/passthrough.ts +17 -12
  19. package/src/adapters/openai-responses/reasoning.ts +68 -1
  20. package/src/adapters/registry.ts +1 -1
  21. package/src/adapters/upstream-http-error.ts +3 -2
  22. package/src/adapters/zed.ts +7 -4
  23. package/src/bridge/internal.ts +11 -1
  24. package/src/bridge/response-json.ts +2 -2
  25. package/src/bridge/sse.ts +10 -4
  26. package/src/claude/inbound.ts +12 -26
  27. package/src/claude/outbound.ts +5 -0
  28. package/src/cli/account-api.ts +13 -1
  29. package/src/cli/account-extended.ts +17 -5
  30. package/src/cli/account-key-quota.ts +4 -1
  31. package/src/cli/account.ts +1 -1
  32. package/src/cli/capabilities-agents-routing.ts +45 -8
  33. package/src/cli/capabilities-base.ts +5 -5
  34. package/src/cli/capabilities-provider-models.ts +5 -4
  35. package/src/cli/combo-result.ts +8 -2
  36. package/src/cli/combo.ts +17 -0
  37. package/src/cli/help.ts +1 -1
  38. package/src/cli/message-command.ts +4 -1
  39. package/src/cli/message-remote-args.ts +69 -0
  40. package/src/cli/message-remote-command.ts +77 -0
  41. package/src/cli/provider-runtime.ts +69 -1
  42. package/src/cli/registry.ts +3 -3
  43. package/src/codex/account-pause.ts +6 -1
  44. package/src/codex/account-runtime-state.ts +5 -0
  45. package/src/codex/account-store.ts +30 -3
  46. package/src/codex/auth-api/account-list.ts +3 -0
  47. package/src/codex/auth-api/main-account-probe.ts +3 -2
  48. package/src/codex/auth-context.ts +9 -4
  49. package/src/codex/catalog/aggregation.ts +15 -0
  50. package/src/codex/catalog/auto-review.ts +2 -1
  51. package/src/codex/catalog/build-entries.ts +17 -7
  52. package/src/codex/catalog/derive-entry.ts +3 -1
  53. package/src/codex/catalog/metadata.ts +2 -0
  54. package/src/codex/catalog/model-visibility.ts +0 -2
  55. package/src/codex/catalog/ownership.ts +71 -0
  56. package/src/codex/catalog/parsing.ts +12 -3
  57. package/src/codex/catalog/retained-sync.ts +2 -1
  58. package/src/codex/catalog/routed-removal.ts +4 -4
  59. package/src/codex/convergence.ts +2 -1
  60. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  61. package/src/codex/low-quota-protection.ts +57 -31
  62. package/src/codex/main-account-cache.ts +77 -3
  63. package/src/codex/main-account.ts +6 -2
  64. package/src/codex/native-residue.ts +2 -3
  65. package/src/codex/quota.ts +26 -2
  66. package/src/combos/failover.ts +2 -1
  67. package/src/combos/jev-model-backend.ts +30 -7
  68. package/src/combos/jev-model-request.ts +20 -0
  69. package/src/combos/jev-quota-activation.ts +153 -0
  70. package/src/combos/jev-quota-config.ts +62 -0
  71. package/src/combos/jev-quota-route.ts +26 -0
  72. package/src/combos/jev-quota.ts +88 -0
  73. package/src/combos/jev.ts +25 -12
  74. package/src/combos/types.ts +27 -0
  75. package/src/compatibility/index.ts +3 -0
  76. package/src/compatibility/openai-responses.ts +8 -1
  77. package/src/compatibility/opengateway-responses.ts +61 -0
  78. package/src/config/account-auto-pause-revisions.ts +136 -0
  79. package/src/config/diagnostics.ts +13 -0
  80. package/src/config/live-reconcile.ts +9 -0
  81. package/src/config/persist-unlocked.ts +10 -1
  82. package/src/config/removed-opencode-free.ts +135 -0
  83. package/src/config/schema/account-auto-pause.ts +14 -0
  84. package/src/config/schema/config-schema.ts +15 -0
  85. package/src/config/schema/leaf-validators.ts +7 -0
  86. package/src/config.ts +10 -3
  87. package/src/generated/compatibility-version.json +344 -144
  88. package/src/images/loop.ts +32 -9
  89. package/src/lib/account-auto-pause-response.ts +16 -0
  90. package/src/lib/bun-runtime.ts +18 -1
  91. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  92. package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
  93. package/src/lib/decision-quota-sink.ts +76 -0
  94. package/src/lib/state-store-registrations.ts +25 -0
  95. package/src/link/ssh-argv.ts +3 -0
  96. package/src/messaging/envelope.ts +6 -3
  97. package/src/messaging/remote-auth.ts +83 -0
  98. package/src/messaging/remote-bridge.ts +270 -0
  99. package/src/messaging/remote-contract.ts +108 -0
  100. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  101. package/src/messaging/remote-enrollment.ts +244 -0
  102. package/src/messaging/remote-envelope.ts +40 -0
  103. package/src/messaging/remote-files.ts +117 -0
  104. package/src/messaging/remote-owner.ts +169 -0
  105. package/src/messaging/remote-ports.ts +56 -0
  106. package/src/messaging/remote-process.ts +131 -0
  107. package/src/messaging/remote-replies.ts +98 -0
  108. package/src/messaging/remote-rpc-admission.ts +32 -0
  109. package/src/messaging/remote-send.ts +72 -0
  110. package/src/messaging/remote-store.ts +111 -0
  111. package/src/messaging/remote-tunnels.ts +65 -0
  112. package/src/messaging/rpc.ts +13 -1
  113. package/src/oauth/account-auto-pause-policy.ts +66 -0
  114. package/src/oauth/account-auto-pause.ts +171 -0
  115. package/src/oauth/account-quota-rank.ts +2 -1
  116. package/src/oauth/anthropic-account-refusal.ts +6 -0
  117. package/src/oauth/anthropic-model-family.ts +8 -0
  118. package/src/oauth/anthropic-model-quota.ts +2 -6
  119. package/src/oauth/api-key-pool-preserve.ts +37 -0
  120. package/src/oauth/index.ts +5 -35
  121. package/src/oauth/store.ts +35 -8
  122. package/src/oauth/types.ts +2 -0
  123. package/src/plugins/upstream-hooks.ts +14 -1
  124. package/src/providers/api-key-pause.ts +32 -0
  125. package/src/providers/api-key-resolve.ts +10 -4
  126. package/src/providers/api-key-selection.ts +6 -1
  127. package/src/providers/api-keys.ts +8 -4
  128. package/src/providers/claude-cli-usage.ts +5 -0
  129. package/src/providers/key-failover.ts +11 -11
  130. package/src/providers/model-rename-fields.ts +1 -0
  131. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  132. package/src/providers/quota/account-cache.ts +15 -1
  133. package/src/providers/quota/anthropic-account-quota.ts +2 -1
  134. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  135. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  136. package/src/providers/quota/antigravity.ts +39 -6
  137. package/src/providers/quota/report-cache.ts +14 -0
  138. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  139. package/src/providers/quota-decision-publication.ts +77 -0
  140. package/src/providers/quota-decision-snapshot.ts +145 -0
  141. package/src/providers/quota-routing-cache.ts +3 -0
  142. package/src/providers/quota-types.ts +6 -1
  143. package/src/providers/quota.ts +3 -2
  144. package/src/providers/registry/entries-extended.ts +8 -52
  145. package/src/providers/registry/model-seeds.ts +1 -1
  146. package/src/providers/registry.ts +1 -2
  147. package/src/providers/stale-vision-classification-migration.ts +1 -1
  148. package/src/responses/parser.ts +18 -10
  149. package/src/responses/web-search-replay.ts +138 -0
  150. package/src/routing/compatibility/behavior.ts +4 -1
  151. package/src/server/auth-cors.ts +7 -0
  152. package/src/server/background-lifecycle.ts +2 -1
  153. package/src/server/chat-native.ts +28 -13
  154. package/src/server/claude-messages.ts +2 -1
  155. package/src/server/index/optional-listeners.ts +6 -0
  156. package/src/server/management/combo-routes.ts +17 -2
  157. package/src/server/management/oauth-account-routes.ts +10 -1
  158. package/src/server/management/provider-completion-test.ts +289 -0
  159. package/src/server/management/provider-routes.ts +9 -1
  160. package/src/server/management/route-registry.ts +2 -0
  161. package/src/server/messages-native.ts +18 -4
  162. package/src/server/messages-shared.ts +4 -1
  163. package/src/server/request-log.ts +7 -2
  164. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  165. package/src/server/responses/adapter-dispatch.ts +77 -28
  166. package/src/server/responses/combo-session-recall.ts +25 -0
  167. package/src/server/responses/combo-stream-preflight.ts +3 -1
  168. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  169. package/src/server/responses/compact.ts +8 -5
  170. package/src/server/responses/context-overflow.ts +141 -2
  171. package/src/server/responses/core-codex-account.ts +7 -1
  172. package/src/server/responses/core-combo.ts +31 -5
  173. package/src/server/responses/core.ts +12 -9
  174. package/src/server/responses/empty-completion-guard.ts +2 -0
  175. package/src/server/responses/fetch-helpers.ts +35 -4
  176. package/src/server/responses/jev-model-invoke.ts +13 -14
  177. package/src/server/responses/native-steering-log.ts +2 -2
  178. package/src/server/responses/passthrough-delivery.ts +3 -2
  179. package/src/server/responses/passthrough-dispatch.ts +30 -33
  180. package/src/server/responses/passthrough-observations.ts +38 -0
  181. package/src/server/responses/request-compression.ts +98 -0
  182. package/src/server/responses/request-transport.ts +32 -3
  183. package/src/server/responses/run-turn-execution.ts +2 -0
  184. package/src/server/responses/sidecar-execution.ts +16 -3
  185. package/src/server/responses/terminal-guard.ts +2 -0
  186. package/src/server/startup-action-control.ts +2 -2
  187. package/src/server/startup-health-cache.ts +2 -2
  188. package/src/server/windows-tray-control.ts +2 -2
  189. package/src/service/definition.ts +9 -1
  190. package/src/types/account-auto-pause.ts +15 -0
  191. package/src/types/config.ts +23 -1
  192. package/src/types/provider.ts +9 -5
  193. package/src/types/request.ts +20 -2
  194. package/src/types.ts +1 -0
  195. package/src/usage/cost.ts +17 -1
  196. package/src/usage/expected-prices.ts +3 -1
  197. package/src/usage/jev-stats.ts +4 -0
  198. package/src/usage/log.ts +2 -0
  199. package/src/usage/summary.ts +7 -0
  200. package/src/web-search/loop.ts +37 -11
  201. package/src/web-search/progress-stream.ts +4 -1
  202. package/src/web-search/run-turn-loop.ts +9 -3
  203. package/src/web-search/sidecar-search-replay.ts +96 -0
  204. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  205. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
@@ -0,0 +1,171 @@
1
+ import { captureAccountAutoPauseFence, accountAutoPauseFenceIsLocal, accountAutoPauseFenceMatchesDisk } from "../config/account-auto-pause-revisions";
2
+ import type { OcxConfig, OcxProviderConfig } from "../types";
3
+ import type { OAuthAccessSnapshot } from "./index";
4
+ import type { CodexAuthContext } from "../codex/auth-context";
5
+ import type { AccountPauseReason } from "../types/account-auto-pause";
6
+ import { credentialGeneration, getAccountSet, setAccountPaused } from "./store";
7
+ import { mutatePersistedConfig } from "../config";
8
+ import { isCodexAccountGenerationLive, readCodexAccountRecord } from "../codex/account-store";
9
+ import { captureMainQuotaDispatch, isMainQuotaDispatchLive } from "../codex/main-account-cache";
10
+ import { isCodexAccountPaused, setCodexAccountPaused } from "../codex/account-pause";
11
+ import { apiKeyPoolEntryId } from "../providers/api-keys";
12
+ import { resolveProviderApiKey } from "../providers/api-key-resolve";
13
+ import { createHash } from "node:crypto";
14
+ import { setProviderApiKeyPaused } from "../providers/api-key-pause";
15
+ import { AccountRefusalStreaks, autoPauseThreshold, permanentAccountRefusal } from "./account-auto-pause-policy";
16
+ import { clearThreadAccountMapForAccount, reconcileCodexActiveAfterExclusion } from "../codex/routing";
17
+ import { isNonReplayableResponse } from "../lib/upstream-retry";
18
+ import { hasUpstreamRewriters, upstreamRewriterEpoch } from "../plugins/upstream-hooks";
19
+
20
+ const streaks = new WeakMap<OcxConfig, AccountRefusalStreaks>();
21
+ export { wasAccountAutoPaused, wasPermanentAccountRefusal } from "../lib/account-auto-pause-response";
22
+ import { markAccountAutoPaused, markPermanentAccountRefusal } from "../lib/account-auto-pause-response";
23
+ export interface AccountAutoPauseBinding {
24
+ config: OcxConfig;
25
+ oauth?: OAuthAccessSnapshot;
26
+ codex?: CodexAuthContext;
27
+ }
28
+
29
+ /** Capture before transport. A later response cannot acquire authority over a newer login/resume. */
30
+ export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
31
+ /** Final-send report: only a response whose wire credential and destination match is counted. */
32
+ physical: (sameDestination: boolean, headers: Headers) => void;
33
+ };
34
+ const CREDENTIAL_HEADERS = ["authorization", "x-api-key", "x-goog-api-key", "api-key"] as const;
35
+ const CODEX_SELECTOR_HEADERS = ["openai-organization", "openai-project"] as const;
36
+ // Nested capture sites (a providerFetch observer around a dispatchOverride's own observer) see the
37
+ // same physical response. It counts once per account, and a response whose final send was reported
38
+ // to any observer is never attributed by an observer that received no report.
39
+ const countedResponses = new WeakMap<Response, Set<string>>();
40
+ const reportedResponses = new WeakSet<Response>();
41
+ export function captureAccountAutoPauseAttempt(name: string, provider: OcxProviderConfig, binding?: AccountAutoPauseBinding, headers?: Headers):
42
+ AccountAutoPauseObserver | undefined {
43
+ if (!binding) return;
44
+ const { config, oauth, codex } = binding;
45
+ const threshold = autoPauseThreshold(config.providers[name]?.accountAutoPause);
46
+ if (!threshold) return;
47
+ const configured = config.providers[name];
48
+ if (!configured || configured.disabled) return;
49
+ const fence = captureAccountAutoPauseFence(config, name);
50
+ if (!fence) return;
51
+ const route = JSON.stringify([configured.baseUrl, configured.adapter, configured.authMode]);
52
+ const policyCurrent = () => accountAutoPauseFenceIsLocal(name, fence) && !config.providers[name]?.disabled
53
+ && autoPauseThreshold(config.providers[name]?.accountAutoPause) === threshold
54
+ && JSON.stringify([config.providers[name]?.baseUrl, config.providers[name]?.adapter, config.providers[name]?.authMode]) === route;
55
+ let key: string, incarnation: string, current: () => boolean, owns: (wire: Headers) => boolean;
56
+ let pause: (reason: AccountPauseReason) => Promise<boolean>;
57
+ if (provider.authMode === "oauth") {
58
+ if (!oauth || oauth.provider !== name || headers?.get("authorization") !== `Bearer ${oauth.accessToken}` || headers.has("x-api-key")) return;
59
+ const row = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
60
+ if (!row || row.paused || credentialGeneration(row.credential) !== oauth.generation) return;
61
+ const revision = row.pauseRevision;
62
+ owns = wire => wire.get("authorization") === `Bearer ${oauth.accessToken}` && !wire.has("x-api-key");
63
+ key = `${name}\0oauth\0${oauth.accountId}`;
64
+ incarnation = `${row.loginId ?? row.addedAt ?? oauth.accountId}\0${revision ?? ""}`;
65
+ current = () => {
66
+ const live = getAccountSet(name)?.accounts.find(a => a.id === oauth.accountId);
67
+ return !!live && !live.paused && live.pauseRevision === revision && credentialGeneration(live.credential) === oauth.generation;
68
+ };
69
+ pause = async reason => (await setAccountPaused(name, oauth.accountId, true, {
70
+ generation: oauth.generation, pauseRevision: revision, isCurrent: policyCurrent,
71
+ assertPolicyBeforePersist: () => { if (!accountAutoPauseFenceMatchesDisk(name, fence)) throw new Error("Account auto-pause policy changed before persistence"); }, details: { reason, pausedAt: Date.now() },
72
+ })).status === "updated";
73
+ } else if (provider.authMode === "forward") {
74
+ if (!codex || codex.kind !== "pool" && codex.kind !== "main-pool") return;
75
+ const { accountId } = codex;
76
+ // The bearer and the workspace together name the Codex account; organization/project selectors
77
+ // must stay as captured, so a rewrite that keeps the bearer cannot redirect the refusal.
78
+ const selectors = CODEX_SELECTOR_HEADERS.map(header => headers?.get(header) ?? null);
79
+ const codexOwns = (wire: Headers) => wire.get("authorization") === `Bearer ${codex.accessToken}`
80
+ && wire.get("chatgpt-account-id") === codex.chatgptAccountId
81
+ && CODEX_SELECTOR_HEADERS.every((header, index) => wire.get(header) === selectors[index]);
82
+ if (!headers || !codexOwns(headers)) return;
83
+ owns = codexOwns;
84
+ const revision = config.codexAccountPauseState?.[accountId]?.revision;
85
+ const mainDispatch = codex.kind === "main-pool" ? captureMainQuotaDispatch(codex.accessToken, codex.chatgptAccountId, codex.writerGeneration) : undefined;
86
+ const credentialCurrent = () => codex.kind === "pool"
87
+ ? isCodexAccountGenerationLive(accountId, codex.generation)
88
+ : !!mainDispatch && isMainQuotaDispatchLive(mainDispatch);
89
+ key = `${name}\0codex\0${accountId}`;
90
+ incarnation = `${codex.kind === "pool" ? readCodexAccountRecord(accountId)?.quotaHistoryIdentity ?? codex.generation : mainDispatch?.credentialGeneration}\0${revision ?? ""}`;
91
+ current = () => !isCodexAccountPaused(config, accountId)
92
+ && config.codexAccountPauseState?.[accountId]?.revision === revision && credentialCurrent();
93
+ pause = async reason => {
94
+ let pauseState: OcxConfig["codexAccountPauseState"];
95
+ const outcome = mutatePersistedConfig(fresh => {
96
+ if (!policyCurrent() || !accountAutoPauseFenceMatchesDisk(name, fence, fresh) || !credentialCurrent() || isCodexAccountPaused(fresh, accountId)
97
+ || fresh.codexAccountPauseState?.[accountId]?.revision !== revision
98
+ || autoPauseThreshold(fresh.providers[name]?.accountAutoPause) !== threshold) return { changed: false, value: false };
99
+ setCodexAccountPaused(fresh, accountId, true, { reason, pausedAt: Date.now() });
100
+ pauseState = structuredClone(fresh.codexAccountPauseState);
101
+ return { changed: true, value: true };
102
+ });
103
+ if (outcome.status !== "committed" || !outcome.value) return false;
104
+ config.pausedCodexAccountIds = [...new Set([...(config.pausedCodexAccountIds ?? []), accountId])];
105
+ config.codexAccountPauseState = pauseState;
106
+ clearThreadAccountMapForAccount(accountId);
107
+ reconcileCodexActiveAfterExclusion(config, accountId);
108
+ return true;
109
+ };
110
+ } else {
111
+ const selection = provider._apiKeyAttempt;
112
+ if (!headers || !provider.apiKey || ![headers.get("authorization"), headers.get("x-api-key"), headers.get("x-goog-api-key")].some(value => value === provider.apiKey || value === `Bearer ${provider.apiKey}`)) return;
113
+ const entry = configured.apiKeyPool?.find(e => e.id === selection?.entryId && e.key === selection.reference)
114
+ ?? (!configured.apiKeyPool?.length && configured.apiKey ? { id: apiKeyPoolEntryId(configured.apiKey), key: configured.apiKey, disabled: false, pauseRevision: undefined } : undefined);
115
+ if (!entry || entry.disabled || !selection) return;
116
+ const { id, key: reference, pauseRevision } = entry;
117
+ key = `${name}\0key\0${id}`;
118
+ const resolvedKey = provider.apiKey;
119
+ owns = wire => {
120
+ const sent = CREDENTIAL_HEADERS.map(header => wire.get(header)).filter((value): value is string => value !== null);
121
+ return sent.length > 0 && sent.every(value => value === resolvedKey || value === `Bearer ${resolvedKey}`);
122
+ };
123
+ incarnation = `${createHash("sha256").update(resolvedKey).digest("hex")}\0${pauseRevision ?? ""}`;
124
+ current = () => {
125
+ const configured = config.providers[name];
126
+ const live = configured?.apiKeyPool?.find(e => e.id === id) ?? (!configured?.apiKeyPool?.length && configured?.apiKey === reference ? entry : undefined);
127
+ return !!live && !live.disabled && live.key === reference && live.pauseRevision === pauseRevision && resolveProviderApiKey(reference) === resolvedKey;
128
+ };
129
+ pause = async reason => setProviderApiKeyPaused(config, name, id, true, {
130
+ selection, pauseRevision, resolvedKey, fence, details: { reason, pausedAt: Date.now() },
131
+ });
132
+ }
133
+ incarnation += `\0${fence.diskRevision}\0${fence.localGen}`;
134
+ let tracker = streaks.get(config);
135
+ if (!tracker) { tracker = new AccountRefusalStreaks(); streaks.set(config, tracker); }
136
+ const observed = new WeakSet<Response>();
137
+ // undefined: no HTTP final-send report (Codex WebSocket). Such a send is attributable only when
138
+ // no rewriter was registered at capture and none was registered or removed before the response,
139
+ // so no plugin could have rewritten the dial in between.
140
+ const rewriterEpoch = hasUpstreamRewriters() ? undefined : upstreamRewriterEpoch();
141
+ let wireOwned: boolean | undefined;
142
+ const observer = (async (response: Response) => {
143
+ if (observed.has(response) || isNonReplayableResponse(response)) return response;
144
+ observed.add(response);
145
+ if (wireOwned !== undefined) reportedResponses.add(response);
146
+ else if (reportedResponses.has(response)) return response;
147
+ if (!(wireOwned ?? (rewriterEpoch !== undefined && upstreamRewriterEpoch() === rewriterEpoch))) return response;
148
+ const counted = countedResponses.get(response) ?? new Set<string>();
149
+ if (counted.has(key)) return response;
150
+ countedResponses.set(response, counted.add(key));
151
+ try {
152
+ const verdict = permanentAccountRefusal(response).then(reason => {
153
+ if (reason) markPermanentAccountRefusal(response);
154
+ return reason;
155
+ });
156
+ const paused = await tracker.observe(key, incarnation, threshold, verdict, response.ok,
157
+ () => policyCurrent() && current(), pause);
158
+ if (paused) {
159
+ markAccountAutoPaused(response);
160
+ // Fixed codes only. Never print the upstream body, provider label, or account identifier.
161
+ console.warn(`[account-auto-pause] paused after ${threshold} consecutive permanent account refusals`);
162
+ }
163
+ } catch {
164
+ // Optional persistence must preserve the original provider response.
165
+ console.warn("[account-auto-pause] could not persist account pause; upstream response preserved");
166
+ }
167
+ return response;
168
+ }) as AccountAutoPauseObserver;
169
+ observer.physical = (sameDestination, wire) => { wireOwned = sameDestination && owns(wire); };
170
+ return observer;
171
+ }
@@ -153,7 +153,8 @@ export function accountResetTimestamp(
153
153
  const quota = getCachedProviderAccountQuota(provider, accountId);
154
154
  if (!quota) return null;
155
155
  const family = classifyModelFamilyForQuota(provider, requestedModelId);
156
- const windows = (quota.customWindows ?? []).filter(w => !family || windowMatchesFamily(w.label, family));
156
+ // Model-attributed rows are diagnostics; one must never become the account's reset time.
157
+ const windows = (quota.customWindows ?? []).filter(w => w.modelId === undefined && (!family || windowMatchesFamily(w.label, family)));
157
158
  const weekly = windows.find(w => /week/i.test(w.label));
158
159
  let rawReset = weekly?.resetAt ?? quota.weeklyResetAt;
159
160
  if (rawReset === undefined && provider !== "google-antigravity" && provider !== "anthropic") {
@@ -1,3 +1,4 @@
1
+ import { wasAccountAutoPaused } from "./account-auto-pause";
1
2
  import { anthropicModelFamily } from "./anthropic-model-quota";
2
3
  /** Narrow pre-output account recovery, fenced to the bearer that physically sent the turn. */
3
4
  import { readBoundedResponseBody } from "../lib/bounded-body";
@@ -117,6 +118,11 @@ export async function rotateAnthropicAccountOnResponseForInstance(
117
118
  const { pauseAnthropicRateAdmission, anthropicRatePauseUntil } = anthropicRatePolicyFor(instance);
118
119
  const { recordAnthropicAccountRefusal, rotateAnthropicAccountOnRefusal,
119
120
  hasAnthropicFailoverQuorum, isAnthropicAccountPoolEnabled, pickAlternateAnthropicAccount } = routing;
121
+ if (wasAccountAutoPaused(response)) {
122
+ if (!options.canRetry || options.signal?.aborted) return null;
123
+ return pickAlternateAnthropicAccount(options.config, sent.accountId, Date.now(),
124
+ options.currentDecision ? options.currentDecision() : options.decision ?? null, options.model, options.excludedAccountIds);
125
+ }
120
126
  const ownedCurrent = () => {
121
127
  if (configuredAnthropicInstance(options.config, instance) !== instance
122
128
  || !anthropicPhysicalSendOwnershipIsCurrent(sent)) return undefined;
@@ -0,0 +1,8 @@
1
+ /** Pure family identity shared by existing hard policy and loaded-only advisory evidence. */
2
+ export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
3
+ /** Return the Anthropic model family (Fable, Opus or Sonnet) named by a model id, or undefined for other models. */
4
+ export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
5
+ return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
6
+ : /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
7
+ : /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
8
+ }
@@ -6,12 +6,8 @@ import type { AnthropicInstanceId } from "../providers/anthropic-instance-id";
6
6
  import type { GenerationContext } from "../lib/state-store-sweeper";
7
7
 
8
8
  export const ANTHROPIC_PASSIVE_FAMILY_MAX_AGE_MS = 30 * 60_000;
9
- export type AnthropicModelFamily = "Fable" | "Opus" | "Sonnet";
10
- export function anthropicModelFamily(model?: string): AnthropicModelFamily | undefined {
11
- return /(?:^|[/])claude-fable-5(?:-|$)/i.test(model ?? "") ? "Fable"
12
- : /(?:^|[/])claude-opus-/i.test(model ?? "") ? "Opus"
13
- : /(?:^|[/])claude-sonnet-/i.test(model ?? "") ? "Sonnet" : undefined;
14
- }
9
+ import { anthropicModelFamily } from "./anthropic-model-family";
10
+ export { anthropicModelFamily, type AnthropicModelFamily } from "./anthropic-model-family";
15
11
 
16
12
  export function anthropicModelPercents(quota: ProviderQuota | null, model?: string): number[] {
17
13
  const family = anthropicModelFamily(model);
@@ -0,0 +1,37 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { sanitizeApiKeyValue } from "../providers/api-keys";
3
+ import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
4
+
5
+ /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
6
+ export function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
7
+ if (!Array.isArray(value)) return undefined;
8
+ const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
9
+ const ids = new Set<string>();
10
+ const keys = new Set<string>();
11
+ for (const entry of value as unknown[]) {
12
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
13
+ const candidate = entry as Record<string, unknown>;
14
+ const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
15
+ const key = sanitizeApiKeyValue(candidate.key);
16
+ if (!id || !key || ids.has(id) || keys.has(key)) continue;
17
+ const label = typeof candidate.label === "string" ? candidate.label : undefined;
18
+ const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
19
+ ? candidate.addedAt
20
+ : undefined;
21
+ const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
22
+ ids.add(id);
23
+ keys.add(key);
24
+ pool.push({
25
+ id,
26
+ key,
27
+ ...(label !== undefined ? { label } : {}),
28
+ ...(addedAt !== undefined ? { addedAt } : {}),
29
+ ...(typeof candidate.disabled === "boolean" ? { disabled: candidate.disabled } : {}),
30
+ ...(typeof candidate.pauseRevision === "string" ? { pauseRevision: candidate.pauseRevision } : {}),
31
+ ...(details.success ? { pauseDetails: details.data } : {}),
32
+ });
33
+ }
34
+ // Retain paused slots too: their metadata and credentials are needed for explicit resume.
35
+ return pool.length > 0 ? pool : undefined;
36
+ }
37
+
@@ -55,6 +55,7 @@ import { loginOrcaRouter, orcaRouterInferenceBaseUrl, refreshOrcaRouterKey } fro
55
55
  import { ANTIGRAVITY_DISCOVERY_UA } from "../adapters/google-antigravity-wire";
56
56
  import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
57
57
  import { apiKeyPoolEntryId, sanitizeApiKeyValue } from "../providers/api-keys";
58
+ import { preservableApiKeyPool } from "./api-key-pool-preserve";
58
59
  import { effectiveGoogleMode, getProviderRegistryEntry, mergeRegistryStaticHeaders, providerMatchesRegistryTransport } from "../providers/registry";
59
60
  import { providerModelsUrl, resolveProviderModelDiscoveryUrl } from "../providers/model-discovery";
60
61
  import { resolveProviderTransport } from "../providers/xai-transport";
@@ -1513,36 +1514,6 @@ export function reconcileOAuthProviders(config: OcxConfig, persist = true): bool
1513
1514
  return true;
1514
1515
  }
1515
1516
 
1516
- /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
1517
- function preservableApiKeyPool(value: unknown): NonNullable<OcxProviderConfig["apiKeyPool"]> | undefined {
1518
- if (!Array.isArray(value)) return undefined;
1519
- const pool: NonNullable<OcxProviderConfig["apiKeyPool"]> = [];
1520
- const ids = new Set<string>();
1521
- const keys = new Set<string>();
1522
- for (const entry of value as unknown[]) {
1523
- if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
1524
- const candidate = entry as Record<string, unknown>;
1525
- const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
1526
- const key = sanitizeApiKeyValue(candidate.key);
1527
- if (!id || !key || ids.has(id) || keys.has(key)) continue;
1528
- const label = typeof candidate.label === "string" ? candidate.label : undefined;
1529
- const addedAt = typeof candidate.addedAt === "number" && Number.isFinite(candidate.addedAt)
1530
- ? candidate.addedAt
1531
- : undefined;
1532
- ids.add(id);
1533
- keys.add(key);
1534
- pool.push({
1535
- id,
1536
- key,
1537
- ...(label !== undefined ? { label } : {}),
1538
- ...(addedAt !== undefined ? { addedAt } : {}),
1539
- });
1540
- }
1541
- // `apiKey` remains the routing source of truth. Keep valid alternate slots even when a
1542
- // hand-edited config left the pool out of sync, rather than deleting usable credentials.
1543
- return pool.length > 0 ? pool : undefined;
1544
- }
1545
-
1546
1517
  const OAUTH_LOGIN_OWNED_PROVIDER_FIELDS = [
1547
1518
  "adapter",
1548
1519
  "baseUrl",
@@ -1597,11 +1568,10 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1597
1568
  // Shared sanitizeApiKeyValue trim / no-CRLF checks from api-key pool writes.
1598
1569
  let storedApiKey = sanitizeApiKeyValue(existing.apiKey);
1599
1570
  const storedApiKeyPool = preservableApiKeyPool(existing.apiKeyPool);
1600
- // Unsafe/blank active key with a usable pool: promote the first safe pool entry so
1601
- // key billing keeps working instead of falling back to oauth while pool keys remain.
1602
- if (storedApiKey === undefined && storedApiKeyPool && storedApiKeyPool.length > 0) {
1603
- storedApiKey = storedApiKeyPool[0]!.key;
1604
- }
1571
+ // Retain all-paused pools without activating any paused credential during login.
1572
+ if (storedApiKeyPool?.some(entry => entry.key === storedApiKey && entry.disabled)) storedApiKey = undefined;
1573
+ if (storedApiKey === undefined) storedApiKey = storedApiKeyPool?.find(entry => !entry.disabled)?.key;
1574
+ if (storedApiKeyPool) next.apiKeyPool = storedApiKeyPool;
1605
1575
  if (storedApiKey !== undefined) {
1606
1576
  const pool = storedApiKeyPool ? [...storedApiKeyPool] : [];
1607
1577
  // Keep routing and listProviderApiKeys in sync: never leave a hidden active key that
@@ -19,6 +19,8 @@
19
19
  * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` —
20
20
  * both append distinct identified accounts under multiauth.
21
21
  */
22
+ import { notifyAuthStoreLoaded } from "../lib/decision-quota-sink";
23
+ import { accountPauseDetailsSchema } from "../config/schema/account-auto-pause";
22
24
  import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
23
25
  import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity";
24
26
  import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity";
@@ -354,17 +356,23 @@ export function credentialGeneration(cred: OAuthCredentials): string {
354
356
  return createHash("sha256").update(JSON.stringify([cred.refresh, cred.access, cred.expires])).digest("hex");
355
357
  }
356
358
 
359
+ /** Load and normalize the OAuth auth store with hardened permissions, and hand the result to the opt-in decision-quota slot (a no-op unless JEV quota signals are active). */
357
360
  function loadAuthStoreInternal(): { store: AuthStore; hadLegacy: boolean } {
358
361
  const path = getAuthStorePath();
359
362
  hardenConfigDir();
360
363
  hardenExistingSecret(path);
361
- if (!existsSync(path)) return { store: {}, hadLegacy: false };
364
+ if (!existsSync(path)) { notifyAuthStoreLoaded({}); return { store: {}, hadLegacy: false }; }
365
+ let result: { store: AuthStore; hadLegacy: boolean };
362
366
  try {
363
- return normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8")));
367
+ result = normalizeAuthStore(JSON.parse(readFileSync(path, "utf-8")));
364
368
  } catch {
365
369
  backupInvalidConfig(path);
370
+ notifyAuthStoreLoaded({});
366
371
  return { store: {}, hadLegacy: false };
367
372
  }
373
+ // Outside the parse try: roster publication is advisory and must never route a loaded store into the invalid-config backup path.
374
+ notifyAuthStoreLoaded(result.store);
375
+ return result;
368
376
  }
369
377
 
370
378
  export function loadAuthStore(): AuthStore {
@@ -403,6 +411,7 @@ export function peekAuthStore(): AuthStore {
403
411
  return snapshot.kind === "ready" ? snapshot.store : {};
404
412
  }
405
413
 
414
+ /** Atomically write the auth store with owner-only permissions, then hand it to the opt-in decision-quota slot. */
406
415
  function persist(store: AuthStore): void {
407
416
  const dir = getConfigDir();
408
417
  assertNotRealHomeUnderTest(dir);
@@ -413,6 +422,7 @@ function persist(store: AuthStore): void {
413
422
  }
414
423
  hardenConfigDir();
415
424
  atomicWriteFile(getAuthStorePath(), JSON.stringify(store, null, 2) + "\n");
425
+ notifyAuthStoreLoaded(store);
416
426
  }
417
427
 
418
428
  export class OAuthFileLockError extends Error { readonly code = "OAUTH_FILE_LOCK_UNAVAILABLE"; constructor(message: string, options?: { cause?: unknown }) { super(message, options); this.name = "OAuthFileLockError"; } }
@@ -638,7 +648,12 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
638
648
  if (candidate.refreshAttentionGeneration === credentialGeneration(credential)) {
639
649
  account.refreshAttentionGeneration = candidate.refreshAttentionGeneration;
640
650
  }
641
- if (candidate.paused === true) account.paused = true;
651
+ if (candidate.paused === true) {
652
+ account.paused = true;
653
+ const details = accountPauseDetailsSchema.safeParse(candidate.pauseDetails);
654
+ if (details.success) account.pauseDetails = details.data;
655
+ }
656
+ if (typeof candidate.pauseRevision === "string") account.pauseRevision = candidate.pauseRevision;
642
657
  const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride);
643
658
  if (threshold !== null) account.autoSwitchThresholdOverride = threshold;
644
659
  if (candidate.needsReauth === true && candidate.needsReauthReason === "verify_account") {
@@ -801,7 +816,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
801
816
  drainOAuthMutations();
802
817
  return result;
803
818
  }
804
- export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
819
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void; withConfigLock?: boolean }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
805
820
  const { store, hadLegacy } = loadAuthStoreInternal();
806
821
  if (hadLegacy) backupLegacyOnce();
807
822
  const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
@@ -814,7 +829,7 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
814
829
  // Only providers whose credentials this mutation actually removed leave the downgrade
815
830
  // backup; a no-op removal names none. The result decides, so it is read here.
816
831
  const scrubbedProviders = options?.scrubLegacyBackup?.(result) ?? [];
817
- options?.assertBeforePersist?.();
832
+ if (!options?.withConfigLock) options?.assertBeforePersist?.();
818
833
  const changedProviders: string[] = [];
819
834
  for (const provider of new Set([...selections.keys(), ...Object.keys(store)])) {
820
835
  const before = selections.get(provider);
@@ -837,7 +852,8 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
837
852
  // Receipt-producing mutations need the revision assigned by the bookkeeping above, not the
838
853
  // provisional value visible inside their callback. Finalization cannot await or mutate disk.
839
854
  options?.finalizeResult?.(result, store);
840
- persist(store);
855
+ const publish = () => { if (options?.withConfigLock) options.assertBeforePersist?.(); persist(store); };
856
+ if (options?.withConfigLock) withConfigMutationLockSync(publish); else publish();
841
857
  if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
842
858
  // A committed observer may establish ordering before the generic selection
843
859
  // publication, but its failure can never turn a durable write into a reported failure.
@@ -1388,17 +1404,23 @@ export async function setAccountPaused(
1388
1404
  provider: string,
1389
1405
  accountId: string,
1390
1406
  paused: boolean,
1407
+ automatic?: { generation: string; pauseRevision?: string; details: import("../types/account-auto-pause").AccountPauseDetails; isCurrent?: () => boolean; assertPolicyBeforePersist?: () => void },
1391
1408
  ): Promise<SetAccountPausedResult> {
1392
1409
  const result = await mutateStore(store => {
1393
1410
  const set = store[provider];
1394
1411
  const account = set?.accounts.find(candidate => candidate.id === accountId);
1395
1412
  if (!set || !account) return { status: "not-found" } as const;
1396
- if ((account.paused === true) === paused) {
1413
+ if (automatic && (automatic.isCurrent?.() === false || credentialGeneration(account.credential) !== automatic.generation
1414
+ || account.pauseRevision !== automatic.pauseRevision)) return { status: "not-found" } as const;
1415
+ if (paused && account.paused === true) {
1397
1416
  return { status: "unchanged", activeAccountId: set.activeAccountId, activeAccountChanged: false } as const;
1398
1417
  }
1399
1418
 
1400
1419
  if (paused) account.paused = true;
1401
1420
  else delete account.paused;
1421
+ account.pauseRevision = randomUUID();
1422
+ if (automatic) account.pauseDetails = automatic.details;
1423
+ else delete account.pauseDetails;
1402
1424
  // Pause is part of selection eligibility even when the operator changes a non-active
1403
1425
  // account. Bump the provider selection revision so observers receive the existing
1404
1426
  // post-persistence roster invalidation and stale automatic proposals cannot commit.
@@ -1432,7 +1454,10 @@ export async function setAccountPaused(
1432
1454
  activeAccountId: set.activeAccountId,
1433
1455
  activeAccountChanged,
1434
1456
  } as const;
1435
- }, [provider, accountId, paused]);
1457
+ }, [provider, accountId, paused], { withConfigLock: !!automatic, assertBeforePersist: () => {
1458
+ automatic?.assertPolicyBeforePersist?.();
1459
+ if (automatic?.isCurrent?.() === false) throw new Error("Account auto-pause policy changed before persistence");
1460
+ } });
1436
1461
  if (result.status === "updated") publishOAuthAccountPauseChange(provider);
1437
1462
  return result;
1438
1463
  }
@@ -1485,6 +1510,8 @@ export async function replaceProviderAccountSet(
1485
1510
  ...(account.needsReauth ? { needsReauth: true } : {}),
1486
1511
  ...(account.refreshAttentionGeneration ? { refreshAttentionGeneration: account.refreshAttentionGeneration } : {}),
1487
1512
  ...(account.paused ? { paused: true } : {}),
1513
+ ...(account.pauseDetails ? { pauseDetails: account.pauseDetails } : {}),
1514
+ ...(account.pauseRevision ? { pauseRevision: account.pauseRevision } : {}),
1488
1515
  ...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}),
1489
1516
  ...(account.needsReauthReason === "verify_account" ? { needsReauthReason: account.needsReauthReason } : {}),
1490
1517
  ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
@@ -96,6 +96,8 @@ export interface ProviderAccount {
96
96
  refreshAttentionGeneration?: string;
97
97
  /** Operator exclusion from generic OAuth account selection until explicitly resumed. */
98
98
  paused?: boolean;
99
+ pauseDetails?: import("../types/account-auto-pause").AccountPauseDetails;
100
+ pauseRevision?: string;
99
101
  /** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */
100
102
  autoSwitchThresholdOverride?: number;
101
103
  /**
@@ -31,13 +31,20 @@ interface Registration {
31
31
  }
32
32
 
33
33
  const registrations: Registration[] = [];
34
+ // Changes on every registration and removal, so an observer can tell that no rewriter could
35
+ // have touched a send between two points even when the slot is empty at both.
36
+ let registrationEpoch = 0;
34
37
 
35
38
  export function registerUpstreamRewriter(name: string, rewrite: UpstreamRewriter): () => void {
36
39
  const registration: Registration = { name, rewrite, disabled: false };
37
40
  registrations.push(registration);
41
+ registrationEpoch += 1;
38
42
  return () => {
39
43
  const index = registrations.indexOf(registration);
40
- if (index >= 0) registrations.splice(index, 1);
44
+ if (index >= 0) {
45
+ registrations.splice(index, 1);
46
+ registrationEpoch += 1;
47
+ }
41
48
  };
42
49
  }
43
50
 
@@ -45,6 +52,11 @@ export function hasUpstreamRewriters(): boolean {
45
52
  return registrations.length > 0;
46
53
  }
47
54
 
55
+ /** Monotonic registration epoch; equal values bracket a window with no registration change. */
56
+ export function upstreamRewriterEpoch(): number {
57
+ return registrationEpoch;
58
+ }
59
+
48
60
  /**
49
61
  * Run every active rewriter over one send. Returns the input untouched (same objects) when
50
62
  * no rewriter is registered, so the common path allocates nothing.
@@ -116,4 +128,5 @@ export function rewriteWebSocketDial(
116
128
 
117
129
  export function resetUpstreamRewritersForTests(): void {
118
130
  registrations.length = 0;
131
+ registrationEpoch += 1;
119
132
  }
@@ -0,0 +1,32 @@
1
+ import { accountAutoPauseFenceMatchesDisk, type AccountAutoPauseFence } from "../config/account-auto-pause-revisions";
2
+ import { randomUUID } from "node:crypto";
3
+ import type { OcxConfig } from "../types";
4
+ import type { ProviderApiKeySelection } from "../types/provider";
5
+ import type { AccountPauseDetails } from "../types/account-auto-pause";
6
+ import { apiKeyPoolEntryId } from "./api-keys";
7
+ import { resolveProviderApiKey } from "./api-key-resolve";
8
+ import { commitProviderApiKeySelection } from "./api-key-selection";
9
+
10
+ export function setProviderApiKeyPaused(config: OcxConfig, name: string, id: string, paused: boolean,
11
+ automatic?: { fence: AccountAutoPauseFence; selection: ProviderApiKeySelection; pauseRevision?: string; resolvedKey?: string; details: AccountPauseDetails }): boolean {
12
+ const result = commitProviderApiKeySelection(config, name, provider => {
13
+ if (automatic && !accountAutoPauseFenceMatchesDisk(name, automatic.fence)) return { changed: false, value: false };
14
+ const pool = provider.apiKeyPool?.length ? provider.apiKeyPool
15
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
16
+ const entry = pool.find(e => e.id === id);
17
+ if (!entry || automatic && (entry.disabled || entry.key !== automatic.selection.reference || entry.pauseRevision !== automatic.pauseRevision
18
+ || provider.accountAutoPause?.enabled !== true || resolveProviderApiKey(entry.key) !== automatic.resolvedKey)) return { changed: false, value: false };
19
+ provider.apiKeyPool = pool;
20
+ entry.disabled = paused;
21
+ entry.pauseRevision = randomUUID();
22
+ if (automatic) entry.pauseDetails = automatic.details;
23
+ else delete entry.pauseDetails;
24
+ if (paused && provider.apiKey === entry.key) {
25
+ const next = provider.apiKeyPool?.find(e => !e.disabled);
26
+ if (next) provider.apiKey = next.key;
27
+ else delete provider.apiKey;
28
+ } else if (!paused && !provider.apiKey) provider.apiKey = entry.key;
29
+ return { changed: true, selectionChanged: true, value: true };
30
+ }, automatic?.selection);
31
+ return result.status === "committed" && result.value;
32
+ }
@@ -16,6 +16,7 @@
16
16
  * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per
17
17
  * account; nothing ever rewrites plaintext into config or its backups.
18
18
  */
19
+ import { invalidateDecisionKeyQuotas, observeDecisionKeyCredential } from "../lib/decision-quota-sink";
19
20
  import { resolveEnvValue } from "../config/proxy-env";
20
21
  import { loadKeyringBinding } from "../lib/keyring-native";
21
22
  import { isGcpCredentialMaterial } from "../lib/gcp-credential-input";
@@ -52,6 +53,7 @@ export function providerKeyResolutionGeneration(): number {
52
53
  /** Test seam: swap the OS entry for an in-memory one and drop caches. */
53
54
  export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
54
55
  entryFactory = factory ?? defaultEntryFactory;
56
+ invalidateDecisionKeyQuotas();
55
57
  resolvedCache.clear();
56
58
  resolutionGeneration++;
57
59
  warnedAccounts.clear();
@@ -59,6 +61,7 @@ export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychai
59
61
 
60
62
  /** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
61
63
  export function invalidateResolvedProviderKeyCache(): void {
64
+ invalidateDecisionKeyQuotas();
62
65
  resolvedCache.clear();
63
66
  resolutionGeneration++;
64
67
  warnedAccounts.clear();
@@ -114,15 +117,18 @@ function readKeychain(account: string, quiet = false): string | undefined {
114
117
  */
115
118
  export function resolveProviderApiKey(value: string | undefined): string | undefined {
116
119
  if (!value) return undefined;
117
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value));
118
- return resolveEnvValue(value);
120
+ const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value)) : resolveEnvValue(value);
121
+ observeDecisionKeyCredential(value, resolved);
122
+ return resolved;
119
123
  }
120
124
 
121
125
  /** The same resolution without the unreadable-reference warning, for observe-only callers. */
122
126
  export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
123
127
  if (!value) return undefined;
124
- if (isKeychainReference(value)) return readKeychain(keychainAccount(value), true);
125
- return resolveEnvValue(value);
128
+ const resolved = isKeychainReference(value) ? readKeychain(keychainAccount(value), true) : resolveEnvValue(value);
129
+ // A quiet read still sees a replaced secret; advisory key evidence published under the old one must not outlive it.
130
+ observeDecisionKeyCredential(value, resolved);
131
+ return resolved;
126
132
  }
127
133
 
128
134
  export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
@@ -13,6 +13,9 @@ import { captureProviderApiKeySelection } from "./api-key-selection-capture";
13
13
  export { captureProviderApiKeySelection } from "./api-key-selection-capture";
14
14
 
15
15
  function matchesSelection(provider: OcxProviderConfig, expected: ProviderApiKeySelection): boolean {
16
+ if (expected.distributed) return provider.apiKeySelectionRevision === expected.revision
17
+ && provider.requestPacing?.enabled === true && provider.requestPacing.keySelection === "round-robin"
18
+ && !!provider.apiKeyPool?.some(entry => !entry.disabled && entry.id === expected.entryId && entry.key === expected.reference);
16
19
  const current = captureProviderApiKeySelection(provider);
17
20
  return current.entryId === expected.entryId && current.reference === expected.reference
18
21
  && current.revision === expected.revision;
@@ -24,10 +27,12 @@ function currentKeyProvider(config: OcxConfig, name: string, selection?: Provide
24
27
  let row = configured;
25
28
  if (selection?.distributed) {
26
29
  const entry = configured.apiKeyPool?.find(key => key.id === selection.entryId && key.key === selection.reference);
27
- if (!entry || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
30
+ if (!entry || entry.disabled || (!allowCooling && isKeyInCooldown(name, entry.id)) || configured.apiKeySelectionRevision !== selection.revision
28
31
  || !configured.requestPacing?.enabled || configured.requestPacing.keySelection !== "round-robin") return null;
29
32
  row = { ...configured, apiKey: entry.key, _apiKeyAttempt: selection };
30
33
  }
34
+ if (row.apiKeyPool?.some(entry => entry.disabled && entry.key === row.apiKey)
35
+ || row.apiKeyPool?.length && row.apiKeyPool.every(entry => entry.disabled)) return null;
31
36
  const current = routedProviderConfig(name, { ...row, _apiKeyAttempt: selection?.distributed ? selection : undefined });
32
37
  if (current.authMode === "oauth" || current.authMode === "forward") return null;
33
38
  if (current.authMode === "key" && !current.keyOptional && !current.apiKey?.trim()) return null;