@bitkyc08/opencodex 2.82.0 → 2.84.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 (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Put one Anthropic account back into the pool after a settled reset grant.
3
+ *
4
+ * A fresh usage read lifts a 429 quota cooldown whose rejected windows read
5
+ * open, whatever its deadline source (#6989), but a grant should not have to
6
+ * wait for one: the grant itself lifts admission, as far as its evidence goes:
7
+ *
8
+ * - `claimedAt` is when the operation was first journaled, before its first
9
+ * claim was sent. A refusal recorded at or after it may already describe the
10
+ * post-reset account (another request can be refused while the claim is in
11
+ * flight), so it stays.
12
+ * - `cleared` is the window list the canonical settlement reported. The
13
+ * cooldown is lifted only when those windows cover every window its refusal
14
+ * named; an empty or partial list leaves it in place.
15
+ *
16
+ * Whatever the routing outcome (other than a newer refusal), the cached bars
17
+ * are dropped and usage is reread with its own request: joining a read that
18
+ * started before the grant could republish the pre-reset bars, and a fresh
19
+ * reading is what lets usage recovery release a cooldown the cleared
20
+ * list did not cover. The reread is best-effort; the grant is already spent.
21
+ *
22
+ * Model-family exclusions are not cleared here. Only that reread may release
23
+ * one, and only when its payload proves the family is no longer rejected.
24
+ */
25
+ import { anthropicRoutingFor, type AnthropicQuotaWindow } from "../oauth/anthropic-routing";
26
+ import type { AnthropicInstanceId } from "./anthropic-instance";
27
+ import { fetchAnthropicAccountQuota } from "./quota/anthropic-account-quota";
28
+ import { forgetCachedProviderAccountQuota } from "./quota/account-cache";
29
+ import { anthropicCooldownRecoveryFor } from "./quota/anthropic-cooldown-recovery";
30
+
31
+ export interface AnthropicResetAdmissionEvidence {
32
+ /** Journal time of the operation's first record, written before the first claim was sent. */
33
+ claimedAt: number;
34
+ /** Windows the canonical settlement reported cleared (reset-grant window names). */
35
+ cleared: readonly string[];
36
+ }
37
+
38
+ const QUOTA_WINDOW_FOR_RESET_WINDOW: Readonly<Record<string, AnthropicQuotaWindow>> = {
39
+ five_hour: "five-hour",
40
+ seven_day: "weekly",
41
+ };
42
+
43
+ export async function admitAnthropicAccountAfterReset(
44
+ instance: AnthropicInstanceId,
45
+ accountId: string,
46
+ evidence: AnthropicResetAdmissionEvidence,
47
+ ): Promise<void> {
48
+ const cleared = new Set<AnthropicQuotaWindow>();
49
+ for (const window of evidence.cleared) {
50
+ const quotaWindow = QUOTA_WINDOW_FOR_RESET_WINDOW[window];
51
+ if (quotaWindow) cleared.add(quotaWindow);
52
+ }
53
+ const outcome = anthropicRoutingFor(instance).clearAnthropicAccountCooldownForReset(accountId, evidence.claimedAt, cleared);
54
+ if (outcome === "newer") return;
55
+ // Move any usage read that started before the grant onto a stale generation
56
+ // before dropping the cached row, so it cannot republish the pre-reset bars.
57
+ // "cleared" and "uncovered" already advanced the generation in routing (an
58
+ // uncovered entry moved with it, so the reread below can still recover it).
59
+ if (outcome === "absent") anthropicCooldownRecoveryFor(instance).noteAnthropicCooldownMutation(accountId);
60
+ forgetCachedProviderAccountQuota(instance, accountId);
61
+ await fetchAnthropicAccountQuota(instance, accountId, true, undefined, true);
62
+ }
@@ -7,7 +7,7 @@
7
7
  * ten minutes (`b5o = 600000`) and mints a new one afterwards; this journal keeps
8
8
  * the same window.
9
9
  *
10
- * Rules (devlog/_plan/260923_claude_reset_grants/010_plan.md, "Ledger contract"):
10
+ * Rules (devlog/_fin/260923_claude_reset_grants/010_plan.md, "Ledger contract"):
11
11
  * - Every read-modify-write runs synchronously inside a cross-process
12
12
  * `BEGIN IMMEDIATE` lock on a sibling SQLite file; nothing awaits inside it,
13
13
  * and the upstream claim always runs after the open record is on disk.
@@ -44,6 +44,8 @@ interface OperationRecord {
44
44
  status: "open" | "settled";
45
45
  code?: string;
46
46
  resetsLeft?: number | null;
47
+ /** Windows the settled claim reported cleared; absent on records written before this field. */
48
+ cleared?: string[];
47
49
  attempts: number;
48
50
  leaseUntil: number;
49
51
  createdAt: number;
@@ -64,7 +66,7 @@ export interface AnthropicResetOperationIdentity {
64
66
 
65
67
  export type AnthropicResetBegin =
66
68
  | { kind: "execute"; attempt: number }
67
- | { kind: "replay"; code: string; resetsLeft: number | null; settledAt: number }
69
+ | { kind: "replay"; code: string; resetsLeft: number | null; settledAt: number; claimedAt: number; cleared: string[] }
68
70
  | { kind: "identity-mismatch" }
69
71
  | { kind: "in-flight" }
70
72
  | { kind: "expired" }
@@ -102,7 +104,9 @@ function isRecordShape(value: unknown): value is OperationRecord {
102
104
  return typeof record.accountId === "string" && typeof record.grantId === "string"
103
105
  && typeof record.orgDigest === "string" && (record.status === "open" || record.status === "settled")
104
106
  && typeof record.attempts === "number" && typeof record.leaseUntil === "number"
105
- && typeof record.createdAt === "number" && typeof record.updatedAt === "number";
107
+ && typeof record.createdAt === "number" && typeof record.updatedAt === "number"
108
+ && (record.cleared === undefined
109
+ || Array.isArray(record.cleared) && record.cleared.every(window => typeof window === "string"));
106
110
  }
107
111
 
108
112
  /** Missing file → empty journal. Present but unreadable → throws; never an empty fallback. */
@@ -197,7 +201,10 @@ export function beginAnthropicResetOperation(
197
201
  return { kind: "identity-mismatch" };
198
202
  }
199
203
  if (existing.status === "settled") {
200
- return { kind: "replay", code: existing.code ?? "unavailable", resetsLeft: existing.resetsLeft ?? null, settledAt: existing.updatedAt };
204
+ return {
205
+ kind: "replay", code: existing.code ?? "unavailable", resetsLeft: existing.resetsLeft ?? null,
206
+ settledAt: existing.updatedAt, claimedAt: existing.createdAt, cleared: existing.cleared ?? [],
207
+ };
201
208
  }
202
209
  if (existing.leaseUntil > now) return { kind: "in-flight" };
203
210
  if (now - existing.createdAt >= ANTHROPIC_RESET_RETRY_WINDOW_MS) return { kind: "expired" };
@@ -232,6 +239,12 @@ export function beginAnthropicResetOperation(
232
239
  export interface AnthropicResetSettlement {
233
240
  code: string;
234
241
  resetsLeft: number | null;
242
+ /** When the canonical (first) settlement was journaled, not when this caller arrived. */
243
+ settledAt: number;
244
+ /** When the operation was first journaled, before its first claim was sent. */
245
+ claimedAt: number;
246
+ /** Windows the canonical settlement reported cleared. */
247
+ cleared: string[];
235
248
  }
236
249
 
237
250
  /**
@@ -241,7 +254,7 @@ export interface AnthropicResetSettlement {
241
254
  * throws, because an answer with no journal entry cannot be reported as durable.
242
255
  */
243
256
  export function settleAnthropicResetOperation(
244
- settlement: { operationId: string; code: string; resetsLeft: number | null },
257
+ settlement: { operationId: string; code: string; resetsLeft: number | null; cleared?: readonly string[] },
245
258
  options: AnthropicResetLedgerOptions = {},
246
259
  ): AnthropicResetSettlement {
247
260
  const now = options.now ?? Date.now();
@@ -250,14 +263,23 @@ export function settleAnthropicResetOperation(
250
263
  const journal = readJournal(filePath);
251
264
  const existing = journal.operations[settlement.operationId];
252
265
  if (!existing) throw new AnthropicResetLedgerError("unavailable");
253
- if (existing.status === "settled") return { code: existing.code ?? "unavailable", resetsLeft: existing.resetsLeft ?? null };
266
+ if (existing.status === "settled") {
267
+ return {
268
+ code: existing.code ?? "unavailable", resetsLeft: existing.resetsLeft ?? null,
269
+ settledAt: existing.updatedAt, claimedAt: existing.createdAt, cleared: existing.cleared ?? [],
270
+ };
271
+ }
254
272
  existing.status = "settled";
255
273
  existing.code = settlement.code;
256
274
  existing.resetsLeft = settlement.resetsLeft;
275
+ existing.cleared = [...(settlement.cleared ?? [])];
257
276
  existing.leaseUntil = 0;
258
277
  existing.updatedAt = now;
259
278
  writeJournal(filePath, journal, now);
260
- return { code: settlement.code, resetsLeft: settlement.resetsLeft };
279
+ return {
280
+ code: settlement.code, resetsLeft: settlement.resetsLeft, settledAt: now,
281
+ claimedAt: existing.createdAt, cleared: [...existing.cleared],
282
+ };
261
283
  });
262
284
  }
263
285
 
@@ -39,17 +39,25 @@ function defaultEntryFactory(service: string, account: string): ProviderKeychain
39
39
  let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory;
40
40
  const resolvedCache = new Map<string, string>();
41
41
  const warnedAccounts = new Set<string>();
42
+ let resolutionGeneration = 0;
43
+
44
+ /** Advances whenever cached secrets may have changed, so derived snapshots can rebuild. */
45
+ export function providerKeyResolutionGeneration(): number {
46
+ return resolutionGeneration;
47
+ }
42
48
 
43
49
  /** Test seam: swap the OS entry for an in-memory one and drop caches. */
44
50
  export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void {
45
51
  entryFactory = factory ?? defaultEntryFactory;
46
52
  resolvedCache.clear();
53
+ resolutionGeneration++;
47
54
  warnedAccounts.clear();
48
55
  }
49
56
 
50
57
  /** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */
51
58
  export function invalidateResolvedProviderKeyCache(): void {
52
59
  resolvedCache.clear();
60
+ resolutionGeneration++;
53
61
  warnedAccounts.clear();
54
62
  }
55
63
 
@@ -76,7 +84,7 @@ export function providerKeychainEntry(account: string): ProviderKeychainEntry {
76
84
  return entryFactory(PROVIDER_KEYCHAIN_SERVICE, account);
77
85
  }
78
86
 
79
- function readKeychain(account: string): string | undefined {
87
+ function readKeychain(account: string, quiet = false): string | undefined {
80
88
  const cached = resolvedCache.get(account);
81
89
  if (cached !== undefined) return cached;
82
90
  try {
@@ -88,7 +96,7 @@ function readKeychain(account: string): string | undefined {
88
96
  } catch {
89
97
  // fall through to the single warning below
90
98
  }
91
- if (!warnedAccounts.has(account)) {
99
+ if (!quiet && !warnedAccounts.has(account)) {
92
100
  warnedAccounts.add(account);
93
101
  console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`);
94
102
  }
@@ -105,6 +113,13 @@ export function resolveProviderApiKey(value: string | undefined): string | undef
105
113
  return resolveEnvValue(value);
106
114
  }
107
115
 
116
+ /** The same resolution without the unreadable-reference warning, for observe-only callers. */
117
+ export function resolveProviderApiKeyQuietly(value: string | undefined): string | undefined {
118
+ if (!value) return undefined;
119
+ if (isKeychainReference(value)) return readKeychain(keychainAccount(value), true);
120
+ return resolveEnvValue(value);
121
+ }
122
+
108
123
  export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none";
109
124
 
110
125
  export function providerKeyStoreKind(provider: Pick<OcxProviderConfig, "apiKey"> | undefined): ProviderKeyStoreKind {
@@ -4,7 +4,7 @@
4
4
  * `devin-cli` used to be a local provider whose adapter spawned `devin acp`;
5
5
  * it later became an account provider on the shared `devin` adapter, and the
6
6
  * registry id itself has since merged into `devin` outright
7
- * (devlog/_plan/260913_devin_provider_merge).
7
+ * (devlog/_fin/260913_devin_provider_merge).
8
8
  *
9
9
  * Only the adapter repair still lives here. The `authMode` half — rewriting
10
10
  * `"local"` to `"oauth"` on the registry-id row — moved into
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Merge the retired `devin-cli` provider id into `devin` at startup
3
- * (devlog/_plan/260913_devin_provider_merge).
3
+ * (devlog/_fin/260913_devin_provider_merge).
4
4
  *
5
5
  * The two registry entries were the same adapter, api-server, and credential
6
6
  * shape — only the login path differed — so `devin-cli` was removed from the
@@ -0,0 +1,169 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxParsedRequest, OcxProviderConfig } from "../types";
3
+ import { readBoundedResponseBody } from "../lib/bounded-body";
4
+ import { ProviderOutboundSendCancelledError, providerOutboundGet, providerOutboundPost } from "../lib/provider-outbound";
5
+ import { githubCopilotHttpError, resolveCopilotApiBaseUrl } from "../oauth/github-copilot";
6
+ import { resolveGithubCopilotTransport } from "./github-copilot-transport";
7
+ import { waitForProviderRequestSlot, releaseProviderRequestSlot } from "./request-pacing";
8
+
9
+ export interface CopilotModel {
10
+ id: string;
11
+ model_picker_enabled?: boolean;
12
+ supported_endpoints?: string[];
13
+ [key: string]: unknown;
14
+ }
15
+ const catalogs = new Map<string, { expires: number; models: CopilotModel[] }>();
16
+ const MAX_MODELS = 512;
17
+ const MAX_RESPONSE_BYTES = 1_048_576;
18
+ export class CopilotAutoHttpError extends Error {
19
+ /** Preserve only the upstream status and bounded retry delay; never retain response bodies. */
20
+ constructor(readonly status: number, readonly retryAfter?: string) {
21
+ super(githubCopilotHttpError("Auto negotiation", status).message);
22
+ }
23
+ }
24
+ /** Reject array/scalar payloads before interpreting negotiation objects. */
25
+ function record(value: unknown): Record<string, unknown> | undefined {
26
+ return value !== null && typeof value === "object" && !Array.isArray(value)
27
+ ? value as Record<string, unknown> : undefined;
28
+ }
29
+ /** Accept a bounded, homogeneous list so malformed pools cannot grant routing eligibility. */
30
+ function strings(value: unknown): string[] {
31
+ return Array.isArray(value) && value.length <= MAX_MODELS
32
+ && value.every(/** Require every pool entry to be a bounded nonempty string before it can authorize model selection. */
33
+ v => typeof v === "string" && v.length > 0 && v.length <= 256)
34
+ ? value as string[] : [];
35
+ }
36
+ /** Isolate discovery caches by endpoint and credential without storing plaintext cache keys. */
37
+ function authority(provider: OcxProviderConfig): string {
38
+ return createHash("sha256").update(JSON.stringify([provider.baseUrl, provider.apiKey])).digest("hex");
39
+ }
40
+ /** Make one paced, origin-fenced negotiation send with bounded decoding and secret-safe errors. */
41
+ async function requestJson(provider: OcxProviderConfig, path: string, signal?: AbortSignal, body?: unknown,
42
+ sessionToken?: string, beforeSend?: () => boolean, concurrency = true): Promise<Record<string, unknown>> {
43
+ const boundedSignal = signal ? AbortSignal.any([signal, AbortSignal.timeout(8_000)]) : AbortSignal.timeout(8_000);
44
+ boundedSignal.throwIfAborted();
45
+ const headers = new Headers(provider.headers);
46
+ headers.set("Authorization", `Bearer ${provider.apiKey}`);
47
+ headers.set("X-GitHub-Api-Version", "2026-08-01");
48
+ headers.set("Content-Type", "application/json");
49
+ headers.delete("Copilot-Session-Token");
50
+ if (sessionToken) headers.set("Copilot-Session-Token", sessionToken);
51
+ const url = `${provider.baseUrl}${path}`;
52
+ // Queued-dispatch rebuilding can already own a provider concurrency lease; that
53
+ // caller opts out of taking a second lease while retaining interval pacing.
54
+ const slot = await waitForProviderRequestSlot("github-copilot", provider, "auto", boundedSignal, { concurrency });
55
+ try {
56
+ const init = { headers, signal: boundedSignal };
57
+ const response = body === undefined
58
+ ? await providerOutboundGet("github-copilot", provider, url, init, { beforeSend })
59
+ : await providerOutboundPost("github-copilot", provider, url, { ...init, body: JSON.stringify(body) }, { beforeSend });
60
+ // Status-only failures: session endpoints can echo secret material in their bodies.
61
+ if (!response.ok) {
62
+ void response.body?.cancel().catch(/** Ignore cleanup failures after discarding a refusal body that may contain reflected credentials. */
63
+ () => undefined);
64
+ const retry = response.headers.get("retry-after");
65
+ throw new CopilotAutoHttpError(response.status, retry && /^\d{1,4}$/.test(retry)
66
+ ? String(Math.max(1, Math.min(3600, Number(retry)))) : undefined);
67
+ }
68
+ const bounded = await readBoundedResponseBody(response, { maxBytes: MAX_RESPONSE_BYTES,
69
+ signal: boundedSignal, totalTimeoutMs: 8_000, fatalUtf8: true });
70
+ if (bounded.truncated) throw new Error("GitHub Copilot Auto negotiation returned an incomplete response");
71
+ let value: unknown;
72
+ try { value = JSON.parse(bounded.text); } catch { /* never include raw bodies */ }
73
+ const payload = record(value);
74
+ if (!payload) throw new Error("GitHub Copilot Auto negotiation returned invalid JSON");
75
+ return payload;
76
+ } catch (error) {
77
+ if (error instanceof CopilotAutoHttpError || error instanceof ProviderOutboundSendCancelledError) throw error;
78
+ // Transport/decoder exception messages may contain reflected header values.
79
+ throw new Error(boundedSignal.aborted ? "GitHub Copilot Auto negotiation cancelled"
80
+ : "GitHub Copilot Auto negotiation transport failed");
81
+ } finally { releaseProviderRequestSlot(slot); }
82
+ }
83
+
84
+ /** Detect Auto-only permission only when every row explicitly denies named selection. */
85
+ export function copilotRequiresAuto(_provider: OcxProviderConfig, models: CopilotModel[]): boolean {
86
+ return models.length > 0 && models.every(/** Missing or malformed metadata cannot establish an account-wide picker denial. */
87
+ model => model.model_picker_enabled === false);
88
+ }
89
+ /** Retain legacy rows with unknown permissions; only an explicit denial removes a named picker row. */
90
+ export function copilotPickerModels(provider: OcxProviderConfig, models: CopilotModel[]): CopilotModel[] {
91
+ if (copilotRequiresAuto(provider, models)) return [{ id: "auto" }];
92
+ return models.filter(/** Preserve named selection unless the provider explicitly marks the row unavailable. */
93
+ model => model.model_picker_enabled !== false);
94
+ }
95
+ /** Read the credential-bound catalog; callers may disable caching or reuse an existing lease. */
96
+ export async function fetchCopilotAutoModels(provider: OcxProviderConfig, signal?: AbortSignal,
97
+ beforeSend?: () => boolean, cacheTtlMs = 60_000, concurrency = true): Promise<CopilotModel[]> {
98
+ const key = authority(provider);
99
+ const cached = catalogs.get(key);
100
+ if (cacheTtlMs > 0 && cached && cached.expires > Date.now()) return cached.models;
101
+ const payload = await requestJson(provider, "/models", signal, undefined, undefined, beforeSend, concurrency);
102
+ if (!Array.isArray(payload.data) || payload.data.length > MAX_MODELS)
103
+ throw new Error("GitHub Copilot model discovery returned an invalid catalog");
104
+ const models = payload.data.flatMap(/** Discard malformed catalog rows while retaining metadata for bounded model identifiers. */
105
+ value => {
106
+ const row = record(value);
107
+ if (!row || typeof row.id !== "string" || !row.id || row.id.length > 256) return [];
108
+ return [{ ...row, id: row.id } as CopilotModel];
109
+ });
110
+ if (cacheTtlMs > 0) {
111
+ if (!catalogs.has(key) && catalogs.size >= 32) catalogs.delete(catalogs.keys().next().value!);
112
+ catalogs.set(key, { expires: Date.now() + Math.min(cacheTtlMs, 60_000), models });
113
+ }
114
+ return models;
115
+ }
116
+ /** Reset discovery state between isolated account/permission regression cases. */
117
+ export function clearCopilotAutoModelsForTests(): void { catalogs.clear(); }
118
+
119
+ /** Resolve before adapter construction: the server can choose either wire on every turn. */
120
+ export async function resolveCopilotAuto(provider: OcxProviderConfig, requestedModel: string,
121
+ parsed: OcxParsedRequest, signal?: AbortSignal, beforeSend?: () => boolean, concurrency = true): Promise<{
122
+ provider: OcxProviderConfig; modelId: string; auto: boolean; expiresAt?: number;
123
+ }> {
124
+ const transport = resolveGithubCopilotTransport(provider, provider.authMode === "oauth"
125
+ ? resolveCopilotApiBaseUrl(provider.baseUrl) : undefined);
126
+ // Never inherit a session token from a previous account or persist it in config.
127
+ const cleanHeaders = new Headers(transport.headers);
128
+ cleanHeaders.delete("Copilot-Session-Token");
129
+ transport.headers = Object.fromEntries(cleanHeaders);
130
+ let models: CopilotModel[];
131
+ try { models = await fetchCopilotAutoModels(transport, signal, beforeSend, 60_000, concurrency); }
132
+ catch (error) {
133
+ if (signal?.aborted || error instanceof ProviderOutboundSendCancelledError) throw error;
134
+ // Permission discovery is additive for legacy paid configurations. A temporary
135
+ // discovery outage must not turn a previously usable named route into a refusal.
136
+ return { provider: transport, modelId: requestedModel, auto: false };
137
+ }
138
+ if (!copilotRequiresAuto(provider, models))
139
+ return { provider: transport, modelId: requestedModel, auto: false };
140
+ const session = await requestJson(transport, "/models/session", signal,
141
+ { auto_mode: { model_hints: ["auto"] } }, undefined, beforeSend, concurrency);
142
+ const pool = strings(session.available_models);
143
+ if (typeof session.session_token !== "string" || !session.session_token || session.session_token.length > 32_768 || !/^[\x21-\x7e]+$/.test(session.session_token) || !pool.length)
144
+ throw new Error("GitHub Copilot Auto session omitted its routing credentials");
145
+ if (session.expires_at !== undefined && (typeof session.expires_at !== "number" || !Number.isFinite(session.expires_at)))
146
+ throw new Error("GitHub Copilot Auto session returned an invalid expiry");
147
+ const expiresAt = typeof session.expires_at === "number" && Number.isFinite(session.expires_at)
148
+ ? session.expires_at * 1000 : Date.now() + 60_000;
149
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now() + 1000) throw new Error("GitHub Copilot Auto session is already expired");
150
+ const lastUser = [...parsed.context.messages].reverse().find(/** Find the newest user turn as the intent-routing source instead of replaying earlier conversation text. */
151
+ message => message.role === "user");
152
+ const prompt = lastUser?.role === "user"
153
+ ? typeof lastUser.content === "string" ? lastUser.content : lastUser.content.flatMap(/** Extract text parts for intent negotiation without forwarding image or other multimodal payloads. */
154
+ part => part.type === "text" ? [part.text] : []).join("\n")
155
+ : "";
156
+ const intent = await requestJson(transport, "/models/session/intent", signal,
157
+ { prompt: prompt.slice(0, 32_768), available_models: pool }, session.session_token, beforeSend, concurrency);
158
+ const modelId = strings(intent.candidate_models).find(/** Choose the first server-ranked candidate that belongs to this credential-bound session pool. */
159
+ candidate => pool.includes(candidate));
160
+ if (!modelId) throw new Error("GitHub Copilot Auto intent returned no eligible model");
161
+ const endpoints = strings(models.find(/** Read endpoint capabilities for the selected model before choosing its inference adapter. */
162
+ model => model.id === modelId)?.supported_endpoints);
163
+ const adapter = endpoints.includes("/responses") ? "openai-responses"
164
+ : endpoints.includes("/chat/completions") ? "openai-chat" : undefined;
165
+ if (!adapter) throw new Error("GitHub Copilot Auto selected a model with no supported inference endpoint");
166
+ return { auto: true, modelId, expiresAt: Math.min(expiresAt, Date.now() + 60_000), provider: { ...transport, adapter,
167
+ ...(adapter === "openai-responses" ? { responsesPath: "/responses" } : { chatCompletionsPath: "/chat/completions" }),
168
+ headers: { ...transport.headers, "X-GitHub-Api-Version": "2026-08-01", "Copilot-Session-Token": session.session_token } } };
169
+ }
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
10
10
  */
11
- import { commitProviderApiKeySelection } from "./api-key-selection";
11
+ import { commitProviderApiKeySelection, providerApiKeySelectionIsCurrent } from "./api-key-selection";
12
12
  import type { ProviderApiKeySelection } from "../types/provider";
13
13
  import { routedProviderConfig } from "../router";
14
14
  import { getProviderRegistryEntry } from "./registry";
@@ -30,6 +30,16 @@ interface KeyCooldown {
30
30
 
31
31
  const DEFAULT_COOLDOWN_MS = 60_000;
32
32
  const MAX_COOLDOWN_MS = 10 * 60_000; // cap at 10 min for api-key rotation
33
+ /**
34
+ * Anthropic credit-balance refusal (#6958). An empty prepaid balance is account state, not a
35
+ * rate signal: it clears only when someone buys credits or auto-reload fires, so the
36
+ * ten-minute credential cap would hand a dead key back to rotation six times an hour. One hour
37
+ * keeps it out of replacement and proactive selection while a funded sibling exists, and still
38
+ * notices a top-up without a restart. When every key is held, requests keep using the active
39
+ * key and return Anthropic's original refusal. The hold is in memory; dashboard key management
40
+ * clears it.
41
+ */
42
+ export const ANTHROPIC_BILLING_COOLDOWN_MS = 60 * 60_000;
33
43
 
34
44
  /**
35
45
  * Cap for a cooldown the upstream itself dated, as opposed to one we inferred.
@@ -688,12 +698,13 @@ export function rateLimitRetryDelayMs(
688
698
  function rotateKeyAfterFailure(
689
699
  config: OcxConfig,
690
700
  providerName: string,
691
- failureStatus: 401 | 429,
701
+ failureStatus: 401 | 429 | "billing",
692
702
  retryAfterHeader: string | null | undefined,
693
703
  now = Date.now(),
694
704
  attemptedKey?: string,
695
705
  attemptedSelection?: ProviderApiKeySelection,
696
706
  quotaResetAt?: number,
707
+ acceptsFreshProvider?: (provider: OcxProviderConfig) => boolean,
697
708
  ): OcxProviderConfig | null {
698
709
  const provider = config.providers[providerName];
699
710
  if (!provider) return null;
@@ -704,6 +715,11 @@ function rotateKeyAfterFailure(
704
715
  | { failedId?: string; candidateId?: string }
705
716
  | { exhaustedCount: number; failedId?: string };
706
717
  const outcome = commitProviderApiKeySelection<Rotation | null>(config, providerName, freshProvider => {
718
+ // Run on every persisted transaction attempt, including a freshness rebase.
719
+ if (acceptsFreshProvider) {
720
+ try { if (!acceptsFreshProvider(freshProvider)) return { changed: false, value: null }; }
721
+ catch { return { changed: false, value: null }; }
722
+ }
707
723
  const pool = freshProvider.apiKeyPool;
708
724
  if (!pool || pool.length < 2) return { changed: false, value: null };
709
725
 
@@ -741,6 +757,8 @@ function rotateKeyAfterFailure(
741
757
  }, attemptedSelection);
742
758
  if (outcome.status === "unavailable") return null;
743
759
  if (outcome.status === "superseded") {
760
+ // Billing authority is bound to the sent selection; a newer manual choice grants no replay.
761
+ if (acceptsFreshProvider) return null;
744
762
  // A newer manual selection (including A→B→A) owns subsequent dispatch. Reusing the
745
763
  // same failed key here would loop forever; preserve its original failure instead.
746
764
  return outcome.provider.apiKey !== failedKey ? structuredClone(outcome.provider) : null;
@@ -750,7 +768,9 @@ function rotateKeyAfterFailure(
750
768
  // A 401 is a verdict about the credential itself, not a timing signal: the key is rejected
751
769
  // until an operator replaces it, and upstreams send no Retry-After for it. Hold it for the
752
770
  // full cap instead of the 429 default so a dead key is not re-tried once a minute.
753
- const cooldownMs = failureStatus === 401
771
+ const cooldownMs = failureStatus === "billing"
772
+ ? ANTHROPIC_BILLING_COOLDOWN_MS
773
+ : failureStatus === 401
754
774
  ? MAX_COOLDOWN_MS
755
775
  // A reset instant the upstream dated outranks both the header and the
756
776
  // default: it is the only one of the three that knows when the quota
@@ -762,7 +782,7 @@ function rotateKeyAfterFailure(
762
782
  sweepExpiredOnWrite(now);
763
783
  }
764
784
  if ("exhaustedCount" in outcome.value) {
765
- console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureStatus}; returning the upstream status to the client`);
785
+ console.warn(`[key-failover] ${providerName}: all ${outcome.value.exhaustedCount} keys in cooldown after ${failureLabel(failureStatus)}; returning the upstream status to the client`);
766
786
  return null;
767
787
  }
768
788
 
@@ -771,12 +791,16 @@ function rotateKeyAfterFailure(
771
791
  if (outcome.value.candidateId) {
772
792
  console.warn(
773
793
  // Log ids only — labels are user-supplied free text and could carry secret material.
774
- `[key-failover] ${providerName}: ${failureStatus} on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
794
+ `[key-failover] ${providerName}: ${failureLabel(failureStatus)} on key ${outcome.value.failedId ?? "?"}; rotating to key ${outcome.value.candidateId}`,
775
795
  );
776
796
  }
777
797
  return structuredClone(committed);
778
798
  }
779
799
 
800
+ function failureLabel(failureStatus: 401 | 429 | "billing"): string {
801
+ return failureStatus === "billing" ? "credit-balance refusal" : String(failureStatus);
802
+ }
803
+
780
804
  export function rotateKeyOn429(
781
805
  config: OcxConfig,
782
806
  providerName: string,
@@ -865,6 +889,29 @@ export function rotateProviderTransportOn401(
865
889
  return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
866
890
  }
867
891
 
892
+ /** Exact Anthropic billing refusal: cool the key for ANTHROPIC_BILLING_COOLDOWN_MS. */
893
+ export function rotateProviderTransportOnAnthropicBilling(
894
+ config: OcxConfig,
895
+ providerName: string,
896
+ routedProvider: OcxProviderTransport,
897
+ options: Omit<RotateProviderTransportOptions, "retryAfter"> = {},
898
+ ): OcxProviderTransport | null {
899
+ if (routedProvider.adapter !== "anthropic"
900
+ || (routedProvider.authMode !== undefined && routedProvider.authMode !== "key")
901
+ || !hasKeyPoolFailover(routedProvider)) return null;
902
+ const headerShape = (provider: OcxProviderConfig) => JSON.stringify(Object.entries(provider.headers ?? {}).sort(([a], [b]) => a.localeCompare(b)));
903
+ const sentHeaders = headerShape(routedProvider);
904
+ const acceptsFreshProvider = (fresh: OcxProviderConfig): boolean => fresh.adapter === routedProvider.adapter
905
+ && (fresh.authMode === undefined || fresh.authMode === "key") && hasKeyPoolFailover(fresh)
906
+ && (fresh.apiKeyTransport ?? "x-api-key") === (routedProvider.apiKeyTransport ?? "x-api-key")
907
+ && headerShape(fresh) === sentHeaders
908
+ && providerApiKeySelectionIsCurrent({ ...config, providers: { ...config.providers, [providerName]: fresh } },
909
+ providerName, routedProvider);
910
+ const rotated = rotateKeyAfterFailure(config, providerName, "billing", null, options.now,
911
+ options.attemptedKey, options.attemptedSelection ?? routedProvider._apiKeyAttempt, undefined, acceptsFreshProvider);
912
+ return rotated ? applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey) : null;
913
+ }
914
+
868
915
  function applyRotatedTransport(
869
916
  providerName: string,
870
917
  routedProvider: OcxProviderTransport,
@@ -12,7 +12,7 @@
12
12
  * The preset is a SEED, not a lock. Materialization evaluates these rules against the provider's
13
13
  * current catalog and stores CONCRETE ids in `selectedModels`, so the visibility hot path
14
14
  * (`filterCatalogVisibleModels`) never learns about patterns and an older binary still sees a
15
- * plain allowlist. Design: devlog/_plan/260824_model_ux_aliases_and_defaults/030_default_preset.md
15
+ * plain allowlist. Design: devlog/_fin/260824_model_ux_aliases_and_defaults/030_default_preset.md
16
16
  */
17
17
 
18
18
  import type { OcxProviderConfig } from "../types";
@@ -3,6 +3,7 @@ import { projectModelRenames } from "./model-rename-migration";
3
3
  import { projectStaleContextWindows } from "./stale-context-window-migration";
4
4
  import { projectStaleModelRosters } from "./stale-model-roster-migration";
5
5
  import { projectStaleVisionClassifications } from "./stale-vision-classification-migration";
6
+ import { projectStaleNoVisionSeeds } from "./stale-no-vision-seed-migration";
6
7
  import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration";
7
8
  import type { OcxConfig } from "../types";
8
9
 
@@ -17,12 +18,13 @@ export function projectStartupConfigRepairs(config: OcxConfig): ReturnType<typeo
17
18
  const renames = projectModelRenames(config);
18
19
  const windows = projectStaleContextWindows(renames.config);
19
20
  const vision = projectStaleVisionClassifications(windows.config);
20
- const rosters = projectStaleModelRosters(vision.config);
21
+ const noVision = projectStaleNoVisionSeeds(vision.config);
22
+ const rosters = projectStaleModelRosters(noVision.config);
21
23
  const devinCli = projectDevinCliAuthMode(rosters.config);
22
24
  return {
23
25
  config: devinCli.config,
24
- changed: renames.changed || windows.changed || vision.changed || rosters.changed || devinCli.changed,
25
- warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...rosters.warnings, ...devinCli.warnings],
26
+ changed: renames.changed || windows.changed || vision.changed || noVision.changed || rosters.changed || devinCli.changed,
27
+ warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...noVision.warnings, ...rosters.warnings, ...devinCli.warnings],
26
28
  };
27
29
  }
28
30
 
@@ -99,7 +99,7 @@ export function museUsageWindowsToQuota(usage: unknown): ProviderQuota | null {
99
99
  *
100
100
  * Kept as a thin adapter over the shared mapper: the frame nests the same two windows
101
101
  * under `subscription`, the muse-code/key response nests them under `subs_usage`
102
- * (devlog/_plan/260912_muse_device_oauth/001 C). Two parsers would drift, and the
102
+ * (devlog/_fin/260912_muse_device_oauth/001 C). Two parsers would drift, and the
103
103
  * five-hour-window discrimination above is the part that must not.
104
104
  */
105
105
  export function parseMuseSubscriptionUsage(payload: unknown): ProviderQuota | null {
@@ -473,6 +473,17 @@ export function clearAccountQuotaCache(provider?: string): void {
473
473
  persistAccountQuotaCache();
474
474
  }
475
475
 
476
+ /** Drop one account's cached bars and any joined probe so a reset is not ranked from the old window. */
477
+ export function forgetCachedProviderAccountQuota(provider: string, accountId: string): void {
478
+ hydrateAccountQuotaCache();
479
+ const key = accountCacheKey(provider, accountId);
480
+ accountQuotaCache.delete(key);
481
+ for (const flight of [...accountQuotaInflight.keys()]) {
482
+ if (flight === key || flight.startsWith(key + "\u0000")) accountQuotaInflight.delete(flight);
483
+ }
484
+ persistAccountQuotaCache();
485
+ }
486
+
476
487
  /**
477
488
  * Resolve a bearer for quota probing without silently adopting a newer global
478
489
  * Claude CLI credential into a background multiauth slot.
@@ -20,6 +20,8 @@ import {
20
20
  */
21
21
  export async function fetchAnthropicAccountQuota(
22
22
  instance: AnthropicInstanceId, accountId: string, force: boolean, config: OcxConfig = loadConfig(),
23
+ /** Never join an older account or vendor flight; a reset reread needs post-grant evidence. */
24
+ freshDispatch = false,
23
25
  ): Promise<AccountQuotaCacheEntry> {
24
26
  const unavailable = (): AccountQuotaCacheEntry => ({ ts: Date.now(), quota: null, unavailable: true });
25
27
  const target = config.providers[instance];
@@ -52,7 +54,7 @@ export async function fetchAnthropicAccountQuota(
52
54
  const flightKey = recovery.anthropicCredentialQuotaFlightKey(key, accountId);
53
55
  const flightCurrent = () => ownerCurrent()
54
56
  && recovery.anthropicCredentialQuotaFlightKey(key, accountId) === flightKey;
55
- const joinable = accountQuotaInflight.get(flightKey);
57
+ const joinable = freshDispatch ? undefined : accountQuotaInflight.get(flightKey);
56
58
  if (joinable) {
57
59
  const joined = await joinable;
58
60
  return ownerCurrent() && (joined.isCurrent?.() ?? flightCurrent()) ? joined : unavailable();
@@ -78,7 +80,7 @@ export async function fetchAnthropicAccountQuota(
78
80
  const result = await recovery.probeAnthropicQuotaWithRecovery(accountId, token, fresh => {
79
81
  if (!flightCurrent()) throw new AnthropicQuotaProbeOwnershipError("quota flight changed");
80
82
  recovery.assertAnthropicQuotaSendAllowed(accountId, token);
81
- return fetchAnthropicUsageQuotaForInstance(instance, token, fresh);
83
+ return fetchAnthropicUsageQuotaForInstance(instance, token, fresh || freshDispatch);
82
84
  }, () => ownerCurrent() && mayCommitAccountQuotaKey(key, writerGeneration));
83
85
  if (result && !result.isCurrent()) return unavailable();
84
86
  if (!result && !flightCurrent()) return unavailable();
@@ -344,3 +344,17 @@ export function isProviderQuotaReportCurrent(value: ProviderQuotaReport): boolea
344
344
  return (generation === undefined || isMainAccountIdentityGenerationLive(generation))
345
345
  && (accountReportCurrent.get(value)?.() ?? true);
346
346
  }
347
+
348
+ /**
349
+ * The reports this process already holds for the configured providers, for a passive read (#6959):
350
+ * no probe, token refresh, account snapshot or publication into routing evidence. The Home's own
351
+ * dashboard and traffic keep the cache current.
352
+ */
353
+ export function heldProviderQuotaReports(configuredProviders: readonly string[]): ProviderQuotaResponse {
354
+ const held = getProviderQuotaReportCache()?.response;
355
+ const configured = new Set(configuredProviders);
356
+ return {
357
+ generatedAt: held?.generatedAt ?? Date.now(),
358
+ reports: (held?.reports ?? []).filter(item => configured.has(item.provider) && isProviderQuotaReportCurrent(item)),
359
+ };
360
+ }