@bitkyc08/opencodex 2.80.0 → 2.81.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 (227) hide show
  1. package/README.md +3 -3
  2. package/gui/dist/assets/App-7yhoys8a.js +54 -0
  3. package/gui/dist/assets/Tray-EwAOsP0S.js +1 -0
  4. package/gui/dist/assets/index-RsoaAxXY.js +94 -0
  5. package/gui/dist/assets/quota-summary-D2lN8tDm.js +1 -0
  6. package/gui/dist/index.html +1 -1
  7. package/gui/dist/provider-icons/claude-green.svg +1 -0
  8. package/package.json +3 -2
  9. package/src/adapters/anthropic-model-contract.ts +8 -2
  10. package/src/adapters/anthropic.ts +7 -2
  11. package/src/adapters/codebuddy/mcp-server.ts +2 -25
  12. package/src/adapters/codebuddy/tool-bridge.ts +1 -7
  13. package/src/adapters/coding-agent/mcp-server.ts +33 -0
  14. package/src/adapters/coding-agent/protocol.ts +122 -17
  15. package/src/adapters/coding-agent/tool-catalog.ts +75 -0
  16. package/src/adapters/coding-agent/turn.ts +101 -44
  17. package/src/adapters/cursor/catalog.ts +8 -0
  18. package/src/adapters/cursor/effort-map.ts +2 -0
  19. package/src/adapters/devin/live-models.ts +2 -0
  20. package/src/adapters/kiro-retry.ts +9 -4
  21. package/src/adapters/openai-chat/messages.ts +7 -6
  22. package/src/adapters/physical-send.ts +8 -5
  23. package/src/adapters/qoder/adapter.ts +41 -6
  24. package/src/adapters/qoder/scaffold-guard.ts +2 -3
  25. package/src/adapters/zed.ts +16 -1
  26. package/src/claude/inbound-cache-stabilize.ts +4 -9
  27. package/src/claude/inbound.ts +1 -0
  28. package/src/claude/intercept/model-bindings.ts +1 -0
  29. package/src/claude/intercept/picker-listener.ts +71 -4
  30. package/src/cli/account-anthropic-threshold.ts +14 -5
  31. package/src/cli/account-api.ts +3 -1
  32. package/src/cli/account-extended.ts +13 -9
  33. package/src/cli/account-policy-dto.ts +1 -1
  34. package/src/cli/account-policy.ts +13 -4
  35. package/src/cli/account.ts +3 -3
  36. package/src/cli/capabilities-accounts.ts +2 -2
  37. package/src/cli/capabilities-agents-routing.ts +24 -0
  38. package/src/cli/capabilities-integrations.ts +16 -0
  39. package/src/cli/codex-shim-autorestore.ts +3 -0
  40. package/src/cli/dispatch.ts +10 -1
  41. package/src/cli/help.ts +4 -1
  42. package/src/cli/index.ts +11 -5
  43. package/src/cli/integration-plan-dto.ts +1 -0
  44. package/src/cli/integration-preview.ts +8 -1
  45. package/src/cli/integrations.ts +32 -3
  46. package/src/cli/message-args.ts +34 -0
  47. package/src/cli/message-command.ts +43 -0
  48. package/src/cli/message-runtime.ts +6 -0
  49. package/src/cli/observe.ts +3 -1
  50. package/src/cli/provider.ts +29 -6
  51. package/src/cli/registry.ts +24 -2
  52. package/src/clients/config-export/commandcode.ts +131 -0
  53. package/src/clients/config-export/contracts.ts +12 -0
  54. package/src/clients/config-export.ts +64 -1
  55. package/src/codex/auth-api/account-list.ts +11 -3
  56. package/src/codex/auth-api/main-account-probe.ts +25 -8
  57. package/src/codex/auth-api/pool-mode-gate.ts +33 -9
  58. package/src/codex/auth-api/pool-quota-probe.ts +15 -1
  59. package/src/codex/auth-api/routes.ts +1 -0
  60. package/src/codex/catalog/auto-review.ts +13 -6
  61. package/src/codex/catalog/build-entries.ts +15 -7
  62. package/src/codex/catalog/control-plane.ts +45 -0
  63. package/src/codex/catalog/effort.ts +11 -1
  64. package/src/codex/catalog/gather-capture.ts +5 -3
  65. package/src/codex/catalog/parsing.ts +2 -1
  66. package/src/codex/catalog/provider-models.ts +15 -9
  67. package/src/codex/control-plane-models.ts +6 -0
  68. package/src/combos/jev-decision-contract.ts +25 -2
  69. package/src/combos/jev.ts +3 -2
  70. package/src/combos/types.ts +1 -1
  71. package/src/config/diagnostics.ts +27 -16
  72. package/src/config/load-degrade.ts +34 -0
  73. package/src/config/schema/anthropic-account-pool.ts +143 -0
  74. package/src/config/schema/config-schema.ts +7 -1
  75. package/src/config/schema/leaf-validators.ts +4 -0
  76. package/src/config.ts +2 -0
  77. package/src/generated/compatibility-version.json +320 -184
  78. package/src/generated/model-metadata.ts +5 -4
  79. package/src/images/loop.ts +2 -1
  80. package/src/integrations/catalog-refresh.ts +1 -1
  81. package/src/integrations/mutation-plan.ts +7 -0
  82. package/src/integrations/registry.ts +8 -0
  83. package/src/integrations/state.ts +9 -2
  84. package/src/integrations/writer.ts +8 -2
  85. package/src/lib/lab-live-route-production.ts +9 -1
  86. package/src/lib/request-execution-budget.ts +236 -58
  87. package/src/lib/spend-pool-alias-validation.ts +79 -0
  88. package/src/lib/spend-pool-continuity.ts +34 -0
  89. package/src/lib/spend-reservation-ledger.ts +412 -50
  90. package/src/lib/state-store-registrations.ts +20 -3
  91. package/src/lib/upstream-retry.ts +17 -5
  92. package/src/lib/workflow-budget.ts +53 -11
  93. package/src/messaging/budget.ts +45 -0
  94. package/src/messaging/discovery.ts +57 -0
  95. package/src/messaging/envelope.ts +49 -0
  96. package/src/messaging/input.ts +37 -0
  97. package/src/messaging/rpc.ts +196 -0
  98. package/src/messaging/send.ts +58 -0
  99. package/src/messaging/socket.ts +72 -0
  100. package/src/messaging/types.ts +29 -0
  101. package/src/oauth/anthropic-account-refusal.ts +51 -17
  102. package/src/oauth/anthropic-account-threshold.ts +8 -2
  103. package/src/oauth/anthropic-continuity.ts +15 -1
  104. package/src/oauth/anthropic-model-quota.ts +120 -48
  105. package/src/oauth/anthropic-model-routes.ts +11 -3
  106. package/src/oauth/anthropic-oauth-definitions.ts +98 -0
  107. package/src/oauth/anthropic-pool-config.ts +30 -0
  108. package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
  109. package/src/oauth/anthropic-routing.ts +1202 -866
  110. package/src/oauth/anthropic-send-ownership.ts +41 -0
  111. package/src/oauth/anthropic.ts +4 -1
  112. package/src/oauth/generic-account-failover.ts +3 -1
  113. package/src/oauth/health.ts +8 -6
  114. package/src/oauth/index.ts +90 -173
  115. package/src/oauth/kiro.ts +22 -9
  116. package/src/oauth/login-cli.ts +2 -0
  117. package/src/oauth/model-discovery-auth.ts +43 -0
  118. package/src/oauth/pool-kernel.ts +11 -5
  119. package/src/oauth/pool-settings-capability.ts +5 -3
  120. package/src/oauth/store-anthropic-instance.ts +87 -0
  121. package/src/oauth/store.ts +67 -9
  122. package/src/oauth/token-guardian.ts +2 -0
  123. package/src/oauth/types.ts +2 -0
  124. package/src/protocols/plan-snapshot.ts +5 -0
  125. package/src/protocols/settings.ts +20 -5
  126. package/src/providers/anthropic-instance-id.ts +47 -0
  127. package/src/providers/anthropic-instance.ts +48 -0
  128. package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
  129. package/src/providers/derive.ts +2 -0
  130. package/src/providers/kiro-models.ts +3 -0
  131. package/src/providers/label.ts +1 -1
  132. package/src/providers/model-rename-fields.ts +2 -0
  133. package/src/providers/quota/account-cache.ts +37 -11
  134. package/src/providers/quota/anthropic-account-quota.ts +93 -0
  135. package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
  136. package/src/providers/quota/report-cache.ts +24 -1
  137. package/src/providers/quota/vendor-probes-oauth.ts +46 -14
  138. package/src/providers/quota.ts +22 -32
  139. package/src/providers/registry/entries-core.ts +35 -30
  140. package/src/providers/registry/model-ids.ts +1 -0
  141. package/src/providers/registry/model-seeds.ts +7 -4
  142. package/src/providers/registry/types.ts +3 -0
  143. package/src/providers/registry.ts +3 -3
  144. package/src/responses/spill-store.ts +5 -1
  145. package/src/responses/state/metrics.ts +87 -0
  146. package/src/responses/state/snapshot-codec.ts +6 -0
  147. package/src/responses/state/spill-queue.ts +1 -0
  148. package/src/responses/state/unforced-store-false.ts +55 -0
  149. package/src/responses/state.ts +50 -66
  150. package/src/router.ts +28 -4
  151. package/src/routing/compatibility/assemble.ts +1 -1
  152. package/src/routing/quota.ts +7 -3
  153. package/src/server/auth-cors.ts +5 -0
  154. package/src/server/chat-completions.ts +6 -1
  155. package/src/server/chat-native.ts +45 -25
  156. package/src/server/claude-messages.ts +16 -2
  157. package/src/server/gui-static.ts +38 -4
  158. package/src/server/index/spend-ledger-lifecycle.ts +10 -4
  159. package/src/server/index.ts +4 -2
  160. package/src/server/inference/context.ts +1 -1
  161. package/src/server/lifecycle.ts +1 -1
  162. package/src/server/management/agent-settings-routes.ts +30 -6
  163. package/src/server/management/anthropic-account-threshold.ts +12 -9
  164. package/src/server/management/anthropic-pool-settings.ts +72 -0
  165. package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
  166. package/src/server/management/config-routes.ts +27 -7
  167. package/src/server/management/decision-routes.ts +2 -1
  168. package/src/server/management/integration-routes.ts +25 -2
  169. package/src/server/management/oauth-account-routes.ts +79 -98
  170. package/src/server/management/provider-instance-ownership.ts +44 -0
  171. package/src/server/management/provider-routes.ts +21 -28
  172. package/src/server/management/route-registry.ts +3 -1
  173. package/src/server/management/routing-profile-routes.ts +4 -4
  174. package/src/server/management/system-restart.ts +44 -14
  175. package/src/server/management/system-routes.ts +24 -2
  176. package/src/server/management/vision-sidecar-options.ts +9 -6
  177. package/src/server/management/web-search-sidecar-options.ts +51 -2
  178. package/src/server/management-auth.ts +6 -0
  179. package/src/server/messages-native-eligibility.ts +3 -2
  180. package/src/server/messages-native-oauth.ts +86 -41
  181. package/src/server/messages-native-selector.ts +39 -0
  182. package/src/server/messages-native.ts +84 -25
  183. package/src/server/request-log.ts +5 -0
  184. package/src/server/responses/adapter-continuation.ts +29 -17
  185. package/src/server/responses/adapter-dispatch.ts +47 -17
  186. package/src/server/responses/antigravity-validation-refusal.ts +20 -6
  187. package/src/server/responses/compact.ts +25 -8
  188. package/src/server/responses/compaction-recovery.ts +1 -1
  189. package/src/server/responses/core-combo-native.ts +4 -1
  190. package/src/server/responses/core-combo.ts +16 -11
  191. package/src/server/responses/core-normalize.ts +1 -0
  192. package/src/server/responses/core-options.ts +3 -1
  193. package/src/server/responses/core-replay.ts +5 -0
  194. package/src/server/responses/passthrough-delivery.ts +7 -3
  195. package/src/server/responses/passthrough-dispatch.ts +21 -8
  196. package/src/server/responses/request-prepare.ts +37 -5
  197. package/src/server/responses/request-send-budget.ts +74 -37
  198. package/src/server/responses/request-spend.ts +325 -33
  199. package/src/server/responses/request-transport.ts +132 -52
  200. package/src/server/responses/run-turn-execution.ts +11 -3
  201. package/src/server/responses/sidecar-execution.ts +96 -14
  202. package/src/server/responses-undeclared-tool-guard.ts +5 -1
  203. package/src/server/workflow-refusal.ts +30 -0
  204. package/src/service/ownership-mutation-lease.d.mts +1 -1
  205. package/src/service/ownership-mutation-lease.mjs +5 -7
  206. package/src/sidecar/anthropic-binding.ts +79 -0
  207. package/src/sidecar/auth.ts +76 -2
  208. package/src/types/anthropic-account-pool.ts +33 -0
  209. package/src/types/config.ts +12 -28
  210. package/src/types/provider.ts +5 -0
  211. package/src/types/tools.ts +7 -0
  212. package/src/types.ts +4 -0
  213. package/src/usage/cost.ts +3 -2
  214. package/src/usage/expected-prices.ts +66 -14
  215. package/src/vision/anthropic-describe.ts +16 -3
  216. package/src/vision/index.ts +3 -0
  217. package/src/vision/plan.ts +19 -6
  218. package/src/web-search/alpha-search.ts +7 -1
  219. package/src/web-search/anthropic-executor.ts +16 -4
  220. package/src/web-search/index.ts +7 -3
  221. package/src/web-search/loop.ts +27 -5
  222. package/src/web-search/passthrough-bridge.ts +4 -1
  223. package/src/web-search/sidecar-providers.ts +4 -3
  224. package/gui/dist/assets/App-MbDVfUeC.js +0 -54
  225. package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
  226. package/gui/dist/assets/index-UJt6npXk.js +0 -94
  227. package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
@@ -20,12 +20,46 @@ const MIME_TYPES: Record<string, string> = {
20
20
  */
21
21
  const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/;
22
22
 
23
- export function findGuiDist(): string | null {
23
+ /** `productName` in desktop/src-tauri/tauri.conf.json; Linux packages install resources under `lib/<productName>`. */
24
+ export const DESKTOP_PRODUCT_NAME = "OpenCodex";
25
+
26
+ /**
27
+ * Where a compiled `ocx` looks for `gui/dist`, given the directory holding the executable.
28
+ *
29
+ * Beside the binary covers a release archive and the Windows desktop install. The desktop shell
30
+ * ships `ocx` as a sidecar and its resources where Tauri's `resource_dir` puts them: `../Resources`
31
+ * inside a macOS app bundle, `../lib/<productName>` for a Linux package (`/usr/bin`, `/usr/local/bin`,
32
+ * an AppImage's `usr/bin`). The shell passes that directory as OPENCODEX_GUI_DIST only to the sidecar
33
+ * it starts itself; `ocx ensure` from the Codex shim and the login service start the same binary
34
+ * without the variable, so they find the dashboard here.
35
+ */
36
+ export function standaloneGuiDistCandidates(executableDir: string, platform: NodeJS.Platform = process.platform): string[] {
37
+ const candidates = [join(executableDir, "gui", "dist")];
38
+ if (platform === "darwin") candidates.push(join(executableDir, "..", "Resources", "gui", "dist"));
39
+ if (platform === "linux") candidates.push(join(executableDir, "..", "lib", DESKTOP_PRODUCT_NAME, "gui", "dist"));
40
+ return candidates;
41
+ }
42
+
43
+ /** Overrides for tests; production callers pass nothing and get this process's own layout. */
44
+ export interface GuiDistLookup {
45
+ /** Directory holding a compiled `ocx`, or null when running from source. */
46
+ standaloneDir?: string | null;
47
+ platform?: NodeJS.Platform;
48
+ /** Directory of this module; a source checkout's `gui/dist` is resolved from it. */
49
+ moduleDir?: string;
50
+ }
51
+
52
+ /** Lookup only: checks for `index.html` and never creates, copies or builds anything. */
53
+ export function findGuiDist(lookup: GuiDistLookup = {}): string | null {
54
+ const standaloneDir = lookup.standaloneDir === undefined
55
+ ? (isStandaloneBinary() ? standaloneRoot() : null)
56
+ : lookup.standaloneDir;
57
+ const moduleDir = lookup.moduleDir ?? import.meta.dir;
24
58
  const candidates = [
25
59
  process.env.OPENCODEX_GUI_DIST,
26
- ...(isStandaloneBinary() ? [join(standaloneRoot(), "gui", "dist")] : []),
27
- join(import.meta.dir, "..", "..", "gui", "dist"),
28
- join(import.meta.dir, "..", "..", "..", "gui", "dist"),
60
+ ...(standaloneDir === null ? [] : standaloneGuiDistCandidates(standaloneDir, lookup.platform)),
61
+ join(moduleDir, "..", "..", "gui", "dist"),
62
+ join(moduleDir, "..", "..", "..", "gui", "dist"),
29
63
  ].filter((candidate): candidate is string => Boolean(candidate));
30
64
  for (const c of candidates) {
31
65
  if (existsSync(join(c, "index.html"))) return c;
@@ -6,6 +6,7 @@ import {
6
6
  import {
7
7
  configureSharedSpendLedger,
8
8
  spendPolicyFromConfig,
9
+ waitForSharedSpendReporterDrain,
9
10
  } from "../../lib/spend-reservation-ledger";
10
11
  import { warnIfSyncedStateDirectory } from "../../lib/synced-state-location";
11
12
 
@@ -26,7 +27,7 @@ export function waitForFailedStartRollback(error: unknown): Promise<void> {
26
27
  }
27
28
 
28
29
  export interface SpendLedgerServerLifecycle {
29
- configure(spend: OcxSpendConfig | undefined): void;
30
+ configure(spend: OcxSpendConfig | undefined, poolAliases?: unknown, providerIds?: readonly string[]): void;
30
31
  track<T extends { stop(closeActiveConnections?: boolean): void | Promise<void> }>(server: T): T;
31
32
  release(): void;
32
33
  releaseAfterFailedStart(): Promise<void>;
@@ -49,13 +50,18 @@ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedge
49
50
  owner.release();
50
51
  };
51
52
  return {
52
- configure(spend): void {
53
- configureSharedSpendLedger(spendPolicyFromConfig(spend));
53
+ configure(spend, poolAliases, providerIds): void {
54
+ configureSharedSpendLedger(spendPolicyFromConfig(spend, poolAliases, providerIds));
54
55
  },
55
56
  track<T extends { stop(closeActiveConnections?: boolean): void | Promise<void> }>(server: T): T {
56
57
  // Capture the raw stop before startServer replaces the public method with full teardown.
57
58
  const stop = server.stop.bind(server);
58
- failedStartStops.push(() => stop(true));
59
+ const drainedStop = async (closeActiveConnections?: boolean): Promise<void> => {
60
+ await stop(closeActiveConnections);
61
+ await waitForSharedSpendReporterDrain();
62
+ };
63
+ Object.defineProperty(server, "stop", { configurable: true, value: drainedStop });
64
+ failedStartStops.push(() => drainedStop(true));
59
65
  return server;
60
66
  },
61
67
  release,
@@ -302,7 +302,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
302
302
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(config.appOwnedMemoryBudgetMb));
303
303
  enforceAppOwnedMemoryBudget();
304
304
  // Observe-only mode still journals physical sends, so every server owns before configuring.
305
- spendLedgerLifecycle.configure(config.spend);
305
+ spendLedgerLifecycle.configure(config.spend, config.spendPoolAliases, Object.keys(config.providers));
306
306
  // After ownership: a second server on the same home is refused above, so the process running
307
307
  // this line is the only one appending to usage.jsonl and the only one that may compact it.
308
308
  setUsageLedgerRetention(config.usageLedgerMaxBytes);
@@ -505,7 +505,9 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
505
505
  const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
506
506
  if (!lease) return serverBusyResponse(req, "active turns", policy);
507
507
  // Root, lane, and the refusal that follows from them, all live in ./workflow-refusal.
508
- const workflow = admitHttpWorkflowTurn(req.headers);
508
+ let workflow: ReturnType<typeof admitHttpWorkflowTurn>;
509
+ try { workflow = admitHttpWorkflowTurn(req.headers); }
510
+ catch (error) { lease.release(); throw error; }
509
511
  if (workflow && !workflow.admitted) {
510
512
  lease.release();
511
513
  // withCors, because without Access-Control-Allow-Origin the exposed refusal header is
@@ -34,7 +34,7 @@ export function createInferenceSendBudget(
34
34
  export function expandInferenceOAuthSendBudget(budget: TransientSendBudget | undefined, accounts: number): void {
35
35
  if (!budget) return;
36
36
  const policy = ingressPolicies.get(budget);
37
- if (!policy || budget.used !== 0) return;
37
+ if (!policy || budget.used !== 0 || ("physicalStarted" in budget && Number(budget.physicalStarted) > 0)) return;
38
38
  ingressPolicies.delete(budget);
39
39
  if (accounts < 2 || !Number.isSafeInteger(accounts)) return;
40
40
  const sends = Math.min(accounts, GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST) * TRANSIENT_RETRY_MAX_ATTEMPTS;
@@ -479,7 +479,7 @@ export async function drainAndShutdown(
479
479
  beginBackgroundShellShutdown();
480
480
  try {
481
481
  while (admittedTurns.size > 0 && Date.now() < deadline) {
482
- await Bun.sleep(100);
482
+ await Bun.sleep(Math.min(100, Math.max(0, deadline - Date.now())));
483
483
  }
484
484
  if (admittedTurns.size > 0) {
485
485
  console.warn(`⚠️ Aborting ${admittedTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
@@ -8,6 +8,8 @@ import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCach
8
8
  import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../config/provider-validation";
9
9
  import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../../config/multi-agent-surface";
10
10
  import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance";
11
+ import { anthropicSidecarPatchError } from "../../config/schema/anthropic-account-pool";
12
+ import { isAnthropicInstanceId, type AnthropicInstanceId } from "../../providers/anthropic-instance-id";
11
13
  import {
12
14
  adoptPersistedClaudeCode,
13
15
  DEFAULT_SUBAGENT_MODELS,
@@ -71,9 +73,13 @@ import {
71
73
  webSearchCandidateRows,
72
74
  webSearchModelIsRejected,
73
75
  webSearchModelRejection,
76
+ sidecarAnthropicPoolOptions,
77
+ sidecarSettingsAfterPatch,
78
+ sidecarOptionsAuth,
74
79
  type WebSearchBackend,
75
80
  } from "./web-search-sidecar-options";
76
81
  import { drainAndShutdown } from "../lifecycle";
82
+ import { inheritedAnthropicInstance } from "../../sidecar/auth";
77
83
  import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
78
84
  import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
79
85
  import type { PersistedUsageAttempt } from "../../usage/log";
@@ -1368,6 +1374,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1368
1374
  );
1369
1375
  const webSearchOverride = config.claudeCode?.webSearchSidecar;
1370
1376
  const visionOverride = config.claudeCode?.visionSidecar;
1377
+ const helperParent = config.claudeCode?.model?.includes("/") ? config.claudeCode.model.split("/")[0] : undefined;
1378
+ const helperVision = { ...config.visionSidecar, ...visionOverride };
1379
+ // Same pool the runtime picks (vision/plan.ts preferredVisionBackend): explicit, else inherited
1380
+ // from the Claude model's instance, so a usable inherited Pool 2 reports the Anthropic backend.
1381
+ const helperVisionInstance = helperVision.anthropicInstance ?? inheritedAnthropicInstance(config, helperParent);
1382
+ const helperVisionBackend = helperVision.backend ?? (sidecarOptionsAuth(config, helperVisionInstance).isAnthropicAuth ? "anthropic" : "openai");
1371
1383
  const { firstPartyDesired, readFirstPartyProxyStatus } = await import("../../claude/first-party-settings");
1372
1384
  const { observeClaudeDesktopMode } = await import("../../claude/desktop-first-party");
1373
1385
  const { claudeInterceptEnabled, getClaudeInterceptState } = await import("../../claude/intercept/runtime");
@@ -1411,11 +1423,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1411
1423
  autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
1412
1424
  blockedSkills: config.claudeCode?.blockedSkills ?? null,
1413
1425
  injectAgents: config.claudeCode?.injectAgents !== false,
1426
+ sidecarPools: {
1427
+ webSearchSidecar: sidecarAnthropicPoolOptions(config, { ...config.webSearchSidecar, ...webSearchOverride }, helperParent),
1428
+ visionSidecar: sidecarAnthropicPoolOptions(config, { ...helperVision, backend: helperVisionBackend }, helperParent),
1429
+ },
1414
1430
  ...(webSearchOverride && Object.keys(webSearchOverride).length > 0
1415
- ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } }
1431
+ ? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model, ...(webSearchOverride.anthropicInstance ? { anthropicInstance: webSearchOverride.anthropicInstance } : {}) } }
1416
1432
  : {}),
1417
1433
  ...(visionOverride && Object.keys(visionOverride).length > 0
1418
- ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
1434
+ ? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model, ...(visionOverride.anthropicInstance ? { anthropicInstance: visionOverride.anthropicInstance } : {}) } }
1419
1435
  : {}),
1420
1436
  fastMode: config.fastMode,
1421
1437
  forceAvailable: [...visibleNativeSlugs(config), ...filterCatalogVisibleModels(models, config).map(catalogModelSlug)],
@@ -1563,6 +1579,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1563
1579
  ...(residual ? ["settings_residual"] : []),
1564
1580
  ] });
1565
1581
  }
1582
+ const instanceError = anthropicSidecarPatchError(config, body, true);
1583
+ if (instanceError) return jsonResponse({ error: instanceError }, 400);
1566
1584
  for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
1567
1585
  const section = body[field];
1568
1586
  if (section === undefined || section === null) continue;
@@ -1587,7 +1605,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1587
1605
  // gates cannot drift.
1588
1606
  if (field === "visionSidecar" && typeof section.model === "string" && section.model !== "") {
1589
1607
  const requested = section.model;
1590
- const candidates = await visionCandidateRows(config);
1608
+ const candidates = await visionCandidateRows({ ...config, visionSidecar: {
1609
+ ...config.visionSidecar, ...sidecarSettingsAfterPatch(config.claudeCode?.visionSidecar, section),
1610
+ } });
1591
1611
  const hint = section.backend === "anthropic" || section.backend === "openai"
1592
1612
  || section.backend === "routed"
1593
1613
  ? section.backend
@@ -1610,7 +1630,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1610
1630
  // module as /api/sidecar-settings — a gate on one route and a stale copy on
1611
1631
  // the other is no gate at all.
1612
1632
  if (field === "webSearchSidecar"
1613
- && (section.model !== undefined || section.backend !== undefined)) {
1633
+ && (section.model !== undefined || section.backend !== undefined || section.anthropicInstance !== undefined)) {
1614
1634
  const stored = config.claudeCode?.webSearchSidecar;
1615
1635
  // Validate against the SUBMITTED backend across the whole union, not
1616
1636
  // just openai/anthropic (#2457). allowedBackends above already refused
@@ -1629,7 +1649,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1629
1649
  : typeof section.model === "string"
1630
1650
  ? section.model
1631
1651
  : stored?.model ?? config.webSearchSidecar?.model;
1632
- const candidates = await webSearchCandidateRows(config);
1652
+ const candidates = await webSearchCandidateRows({ ...config, webSearchSidecar: {
1653
+ ...config.webSearchSidecar, ...sidecarSettingsAfterPatch(config.claudeCode?.webSearchSidecar, section),
1654
+ } });
1633
1655
  if (effectiveModel && webSearchModelIsRejected(effectiveBackend, effectiveModel, candidates)) {
1634
1656
  return jsonResponse(webSearchModelRejection(
1635
1657
  "webSearchSidecar.model",
@@ -1650,8 +1672,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
1650
1672
  }
1651
1673
  // The per-field validation above guarantees vision only ever carries the two-member
1652
1674
  // union; the cast is the loop's shared-shape compromise, not a wider write path.
1653
- const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string };
1675
+ const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string; anthropicInstance?: AnthropicInstanceId | null };
1654
1676
  const override = { ...next[field] } as NonNullable<OcxClaudeCodeConfig[typeof field]>;
1677
+ if (requested.anthropicInstance === null) delete override.anthropicInstance;
1678
+ else if (isAnthropicInstanceId(requested.anthropicInstance)) override.anthropicInstance = requested.anthropicInstance;
1655
1679
  if (requested.backend === null) delete override.backend;
1656
1680
  else if (requested.backend !== undefined) override.backend = requested.backend as never;
1657
1681
  if (requested.model === "") delete override.model;
@@ -1,7 +1,8 @@
1
+ import { isAnthropicOAuthInstance, configuredAnthropicInstance } from "../../providers/anthropic-instance";
1
2
  import type { OcxConfig } from "../../types";
2
- import { effectiveAnthropicAccountThreshold, parseAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold";
3
+ import { effectiveAnthropicAccountThresholdForInstance, parseAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold";
3
4
  import { OAUTH_PROVIDERS } from "../../oauth";
4
- import { setAnthropicAccountThreshold } from "../../oauth/store";
5
+ import { setAnthropicAccountThresholdForInstance } from "../../oauth/store";
5
6
  import { jsonResponse } from "../auth-cors";
6
7
  import { readManagementJsonBodyOr } from "./body";
7
8
 
@@ -10,18 +11,20 @@ export async function handleAnthropicAccountThreshold(req: Request, config: OcxC
10
11
  const body = await readManagementJsonBodyOr(req, {});
11
12
  if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "body must be an object" }, 400);
12
13
  const fields = body as Record<string, unknown>;
13
- const definition = OAUTH_PROVIDERS.anthropic;
14
- const effectiveProvider = config.providers.anthropic
14
+ const provider = fields.provider;
15
+ if (typeof provider !== "string" || !isAnthropicOAuthInstance(provider)) return jsonResponse({ error: "account threshold requires Anthropic OAuth" }, 400);
16
+ const definition = OAUTH_PROVIDERS[provider];
17
+ const effectiveProvider = config.providers[provider]
15
18
  ?? definition?.resolveProviderConfig?.(config) ?? definition?.providerConfig;
16
- if (fields.provider !== "anthropic" || effectiveProvider?.authMode !== "oauth") {
19
+ if (effectiveProvider?.authMode !== "oauth" || (provider === "anthropic2" && !configuredAnthropicInstance(config, provider))) {
17
20
  return jsonResponse({ error: "account threshold requires Anthropic OAuth" }, 400);
18
21
  }
19
22
  if (typeof fields.accountId !== "string" || !fields.accountId.trim()) return jsonResponse({ error: "missing accountId" }, 400);
20
23
  const threshold = parseAnthropicAccountThreshold(fields.threshold);
21
24
  if (fields.threshold !== null && threshold === null) return jsonResponse({ error: "threshold must be an integer 0-100 or null" }, 400);
22
- if (!await setAnthropicAccountThreshold(fields.accountId, threshold)) return jsonResponse({ error: "account not found" }, 404);
23
- return jsonResponse({ ok: true, provider: "anthropic", accountId: fields.accountId,
25
+ if (!await setAnthropicAccountThresholdForInstance(provider, fields.accountId, threshold)) return jsonResponse({ error: "account not found" }, 404);
26
+ return jsonResponse({ ok: true, provider, accountId: fields.accountId,
24
27
  autoSwitchThresholdOverride: threshold,
25
- effectiveAutoSwitchThreshold: effectiveAnthropicAccountThreshold(config, { autoSwitchThresholdOverride: threshold ?? undefined }),
26
- autoSwitchThreshold: effectiveAnthropicAccountThreshold(config) });
28
+ effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider, config, { autoSwitchThresholdOverride: threshold ?? undefined }),
29
+ autoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider, config) });
27
30
  }
@@ -0,0 +1,72 @@
1
+ import type { OcxConfig } from "../../types";
2
+ import type { AnthropicAccountPoolConfig } from "../../types/anthropic-account-pool";
3
+ import { configuredAnthropicInstance, type AnthropicInstanceId } from "../../providers/anthropic-instance";
4
+ import { rawAnthropicAccountPool, resolveAnthropicAccountPoolConfig } from "../../oauth/anthropic-pool-config";
5
+ import { readConfigFileSnapshot, validateConfigCandidate } from "../../config/diagnostics";
6
+ import { ConfigWritePublishedError } from "../../config/persist-unlocked";
7
+ import { mutatePersistedConfig } from "../../config";
8
+ import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
9
+ import { jsonResponse } from "../auth-cors";
10
+
11
+ /** Required instance; never synthesizes a deleted B provider row. */
12
+ export function writeAnthropicPoolSettings(config: OcxConfig, instance: AnthropicInstanceId, value: AnthropicAccountPoolConfig): void {
13
+ if (instance === "anthropic") config.anthropicAccountPool = value;
14
+ else {
15
+ const row = config.providers[instance];
16
+ if (!configuredAnthropicInstance(config, instance)) throw new Error("Anthropic pool instance is no longer configured");
17
+ row!.anthropicAccountPool = value;
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Both Anthropic writers share publication-aware recovery from the atomic mutation owner.
23
+ * Recovery covers only the durable write. Once the file holds the new value, a stale live row
24
+ * or a failed reconcile is reported as saved with the fixed bookkeeping warning, never a 500.
25
+ */
26
+ export function persistAnthropicPoolPatch(
27
+ config: OcxConfig,
28
+ instance: AnthropicInstanceId,
29
+ patch: (target: OcxConfig) => { changed: boolean; value: NonNullable<OcxConfig["anthropicAccountPool"]> },
30
+ ): { status: "saved"; warning?: "config_bookkeeping_failed" } | { status: "failed"; response: Response } {
31
+ const before = readConfigFileSnapshot();
32
+ const unknown = () => ({ status: "failed" as const, response: jsonResponse({
33
+ error: "Pool settings save state is unknown; reload settings before editing again",
34
+ code: "config_save_state_unknown",
35
+ }, 409) });
36
+ const publish = (value: AnthropicAccountPoolConfig, warned: boolean): { status: "saved"; warning?: "config_bookkeeping_failed" } => {
37
+ let warning = warned;
38
+ try { writeAnthropicPoolSettings(config, instance, value); } catch { warning = true; }
39
+ try { reconcileLiveStateStores(); } catch { warning = true; }
40
+ return warning ? { status: "saved", warning: "config_bookkeeping_failed" } : { status: "saved" };
41
+ };
42
+ let durable: AnthropicAccountPoolConfig | undefined;
43
+ try {
44
+ const saved = mutatePersistedConfig(target => {
45
+ if (instance === "anthropic2" && !configuredAnthropicInstance(target, instance)) {
46
+ throw new Error("Anthropic pool instance is no longer configured");
47
+ }
48
+ return patch(target);
49
+ });
50
+ if (saved.status === "unavailable") return unknown();
51
+ durable = saved.value;
52
+ } catch (error) {
53
+ const persisted = readConfigFileSnapshot();
54
+ if (persisted.diagnostics.source !== "file" || persisted.raw === undefined) return unknown();
55
+ // File reads may salvage hand edits; recovery success requires a strict, authoritative document.
56
+ let validated: ReturnType<typeof validateConfigCandidate>;
57
+ try { validated = validateConfigCandidate(JSON.parse(persisted.raw.replace(/^\uFEFF/, ""))); }
58
+ catch { return unknown(); }
59
+ if (!validated.ok) return unknown();
60
+ const current = validated.config;
61
+ if (instance === "anthropic2" && !configuredAnthropicInstance(current, instance)) return unknown();
62
+ let expected: AnthropicAccountPoolConfig;
63
+ try { expected = patch(structuredClone(current)).value; } catch { return unknown(); }
64
+ const matches = JSON.stringify(rawAnthropicAccountPool(current, instance)) === JSON.stringify(expected);
65
+ // A failed live write or reconcile must not turn confirmed publication back into a rollback.
66
+ if (matches) return publish(resolveAnthropicAccountPoolConfig(current, instance), true);
67
+ if (error instanceof ConfigWritePublishedError || persisted.raw !== before.raw) return unknown();
68
+ return { status: "failed", response: jsonResponse({ error: "Pool settings could not be saved" }, 500) };
69
+ }
70
+ if (durable === undefined) return unknown();
71
+ return publish(durable, false);
72
+ }
@@ -14,7 +14,11 @@
14
14
  import { jsonResponse } from "../auth-cors";
15
15
  import type { ManagementContext } from "./context";
16
16
  import { getValidAccessSnapshotForAccount } from "../../oauth";
17
- import { captureOAuthAccountSelection, listAccounts } from "../../oauth/store";
17
+ import { configuredAnthropicInstance, isAnthropicInstanceId, type AnthropicInstanceId } from "../../providers/anthropic-instance";
18
+ import { captureAnthropicPhysicalSendOwnership, anthropicPhysicalSendOwnershipIsCurrent } from "../../oauth/anthropic-send-ownership";
19
+ import { anthropicCooldownRecoveryFor } from "../../providers/quota/anthropic-cooldown-recovery";
20
+ import { captureProviderAccountQuotaEpoch } from "../../providers/quota/account-cache";
21
+ import { captureOAuthAccountSelection, getAccountSet, getAccountCredentialWithStatus, listAccounts } from "../../oauth/store";
18
22
  import {
19
23
  ANTHROPIC_RESET_GRANT_ID_RE,
20
24
  AnthropicResetGrantError,
@@ -28,19 +32,20 @@ import {
28
32
  AnthropicResetLedgerError,
29
33
  anthropicOrgDigest,
30
34
  anthropicResetOperationExists,
35
+ anthropicResetJournalPathForInstance,
31
36
  beginAnthropicResetOperation,
32
37
  pendingAnthropicResetOperation,
33
38
  releaseAnthropicResetLease,
34
39
  settleAnthropicResetOperation,
35
40
  } from "../../providers/anthropic-reset-grant-ledger";
36
41
 
37
- const PROVIDER = "anthropic";
38
42
  const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
39
43
 
40
44
  const MESSAGES = {
41
45
  method_not_allowed: "Method not allowed",
42
46
  session_required: "Spending a reset requires a dashboard session.",
43
47
  invalid_json: "Request body must be JSON.",
48
+ invalid_provider: "No matching builtin Anthropic OAuth provider.",
44
49
  invalid_grant_id: "grantId is missing or malformed.",
45
50
  invalid_operation_id: "operationId must be a UUIDv4.",
46
51
  no_account: "No matching Anthropic OAuth account.",
@@ -60,6 +65,9 @@ const MESSAGES = {
60
65
  type ErrorCode = keyof typeof MESSAGES;
61
66
 
62
67
  export interface AnthropicResetGrantRouteDeps {
68
+ provider?: AnthropicInstanceId;
69
+ isCurrent?: () => boolean;
70
+ forProvider?: (provider: AnthropicInstanceId) => AnthropicResetGrantRouteDeps;
63
71
  fetchFn?: typeof globalThis.fetch;
64
72
  journalPath?: string;
65
73
  now?: () => number;
@@ -68,25 +76,64 @@ export interface AnthropicResetGrantRouteDeps {
68
76
  accessTokenFor: (accountId: string) => Promise<string>;
69
77
  }
70
78
 
71
- const defaultDeps: AnthropicResetGrantRouteDeps = {
72
- listAccountIds: () => listAccounts(PROVIDER).map(account => account.id),
73
- activeAccountId: () => captureOAuthAccountSelection(PROVIDER)?.accountId ?? null,
74
- accessTokenFor: async accountId =>
75
- (await getValidAccessSnapshotForAccount(PROVIDER, accountId, { requireUsableAccount: true })).accessToken,
76
- };
79
+ function defaultDepsFor(ctx: ManagementContext, provider: AnthropicInstanceId): AnthropicResetGrantRouteDeps | null {
80
+ // One source: the live management config. A keeps its legacy reach without a providers row;
81
+ // B requires its configured builtin row. A fresh file load here would refuse on any drift.
82
+ const live = ctx.config;
83
+ const target = live.providers?.[provider];
84
+ if (target?.disabled || target && target.authMode !== "oauth" || configuredAnthropicInstance(live, provider) !== provider) return null;
85
+ const identity = JSON.stringify(target ?? null);
86
+ let owner: ReturnType<typeof captureAnthropicPhysicalSendOwnership> = null;
87
+ const targetCurrent = () => configuredAnthropicInstance(live, provider) === provider
88
+ && JSON.stringify(live.providers?.[provider] ?? null) === identity;
89
+ return {
90
+ // Only the journal path: the ledger's numeric `now` option is not the deps clock function.
91
+ provider, journalPath: anthropicResetJournalPathForInstance(provider),
92
+ listAccountIds: () => listAccounts(provider).map(account => account.id),
93
+ activeAccountId: () => captureOAuthAccountSelection(provider)?.accountId ?? null,
94
+ isCurrent: () => {
95
+ if (!targetCurrent() || !owner || !anthropicPhysicalSendOwnershipIsCurrent(owner)) return false;
96
+ const row = getAccountCredentialWithStatus(provider, owner.accountId);
97
+ return !!row && !row.paused && !row.needsReauth;
98
+ },
99
+ accessTokenFor: async accountId => {
100
+ const initial = getAccountSet(provider)?.accounts.find(row => row.id === accountId);
101
+ const recovery = anthropicCooldownRecoveryFor(provider);
102
+ const incarnation = recovery.reserveAnthropicAccountIncarnation(accountId);
103
+ const epoch = captureProviderAccountQuotaEpoch(provider);
104
+ const snapshot = await getValidAccessSnapshotForAccount(provider, accountId, { requireUsableAccount: true });
105
+ const current = getAccountSet(provider)?.accounts.find(row => row.id === accountId);
106
+ if (!initial || !current || initial.loginId !== current.loginId || initial.addedAt !== current.addedAt
107
+ || recovery.anthropicAccountIncarnation(accountId) !== incarnation || captureProviderAccountQuotaEpoch(provider) !== epoch) throw new Error("reset account replaced");
108
+ owner = captureAnthropicPhysicalSendOwnership(snapshot);
109
+ if (!targetCurrent() || !owner) throw new Error("reset owner changed");
110
+ return snapshot.accessToken;
111
+ },
112
+ };
113
+ }
114
+
115
+ function selectDeps(ctx: ManagementContext, requested: unknown, deps?: AnthropicResetGrantRouteDeps): AnthropicResetGrantRouteDeps | null {
116
+ const provider = requested === undefined || requested === null ? "anthropic" : requested;
117
+ if (!isAnthropicInstanceId(provider)) return null;
118
+ const selected = deps?.forProvider?.(provider) ?? deps;
119
+ // Legacy injected dependencies are A-only; B must explicitly bind its own journal.
120
+ if (selected) return (selected.provider ?? "anthropic") === provider
121
+ ? { journalPath: anthropicResetJournalPathForInstance(provider), ...selected, provider } : null;
122
+ return defaultDepsFor(ctx, provider);
123
+ }
77
124
 
78
125
  function fail(ctx: ManagementContext, status: number, code: ErrorCode, extra: Record<string, unknown> = {}): Response {
79
126
  return jsonResponse({ error: { code, message: MESSAGES[code], ...extra } }, status, ctx.req, ctx.config);
80
127
  }
81
128
 
82
- function ledgerFailure(ctx: ManagementContext, error: unknown): Response {
83
- if (error instanceof AnthropicResetLedgerError && error.code === "busy") return fail(ctx, 503, "ledger_busy");
84
- return fail(ctx, 503, "ledger_unavailable");
129
+ function ledgerFailure(ctx: ManagementContext, error: unknown, extra: Record<string, unknown> = {}): Response {
130
+ if (error instanceof AnthropicResetLedgerError && error.code === "busy") return fail(ctx, 503, "ledger_busy", extra);
131
+ return fail(ctx, 503, "ledger_unavailable", extra);
85
132
  }
86
133
 
87
- function readFailure(ctx: ManagementContext, error: unknown): Response {
88
- if (error instanceof AnthropicResetGrantError && error.code === "auth") return fail(ctx, 401, "auth_failed");
89
- return fail(ctx, 502, "upstream_unavailable");
134
+ function readFailure(ctx: ManagementContext, error: unknown, extra: Record<string, unknown> = {}): Response {
135
+ if (error instanceof AnthropicResetGrantError && error.code === "auth") return fail(ctx, 401, "auth_failed", extra);
136
+ return fail(ctx, 502, "upstream_unavailable", extra);
90
137
  }
91
138
 
92
139
  function resolveAccountId(deps: AnthropicResetGrantRouteDeps, requested: unknown): string | null {
@@ -107,16 +154,19 @@ async function tokenFor(deps: AnthropicResetGrantRouteDeps, accountId: string):
107
154
  }
108
155
 
109
156
  async function handleRead(ctx: ManagementContext, deps: AnthropicResetGrantRouteDeps): Promise<Response> {
157
+ const receipt = { provider: deps.provider };
110
158
  const accountId = resolveAccountId(deps, ctx.url.searchParams.get("accountId") ?? undefined);
111
- if (!accountId) return fail(ctx, 400, "no_account");
159
+ if (!accountId) return fail(ctx, 400, "no_account", receipt);
112
160
  const accessToken = await tokenFor(deps, accountId);
113
- if (!accessToken) return fail(ctx, 401, "auth_failed");
161
+ if (!accessToken) return fail(ctx, 401, "auth_failed", receipt);
162
+ if (deps.isCurrent?.() === false) return fail(ctx, 401, "auth_failed", receipt);
114
163
  let status;
115
164
  try {
116
165
  status = await fetchAnthropicResetGrantStatus({ accessToken, fetchFn: deps.fetchFn });
117
166
  } catch (error) {
118
- return readFailure(ctx, error);
167
+ return readFailure(ctx, error, receipt);
119
168
  }
169
+ if (deps.isCurrent?.() === false) return fail(ctx, 401, "auth_failed", receipt);
120
170
  let pendingOperation = null;
121
171
  let journalAvailable = true;
122
172
  try {
@@ -124,10 +174,10 @@ async function handleRead(ctx: ManagementContext, deps: AnthropicResetGrantRoute
124
174
  } catch {
125
175
  journalAvailable = false;
126
176
  }
127
- return jsonResponse({ accountId, ...status, pendingOperation, journalAvailable }, 200, ctx.req, ctx.config);
177
+ return jsonResponse({ provider: deps.provider, accountId, ...status, pendingOperation, journalAvailable }, 200, ctx.req, ctx.config);
128
178
  }
129
179
 
130
- async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRouteDeps): Promise<Response> {
180
+ async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGrantRouteDeps): Promise<Response> {
131
181
  if (ctx.principal !== "gui-session") return fail(ctx, 403, "session_required");
132
182
  let body: Record<string, unknown>;
133
183
  try {
@@ -137,21 +187,27 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
137
187
  } catch {
138
188
  return fail(ctx, 400, "invalid_json");
139
189
  }
190
+ const deps = selectDeps(ctx, body.provider, injected);
191
+ if (!deps) return fail(ctx, 400, "invalid_provider");
192
+ const provider = deps.provider;
193
+ const operationReceipt = { provider };
194
+ const scopedFail = (status: number, code: ErrorCode, extra: Record<string, unknown> = {}) => fail(ctx, status, code, { ...operationReceipt, ...extra });
140
195
  const { grantId, operationId } = body;
141
- if (typeof grantId !== "string" || !ANTHROPIC_RESET_GRANT_ID_RE.test(grantId)) return fail(ctx, 400, "invalid_grant_id");
142
- if (typeof operationId !== "string" || !UUID_V4_RE.test(operationId)) return fail(ctx, 400, "invalid_operation_id");
143
- if (typeof body.accountId !== "string") return fail(ctx, 400, "no_account");
196
+ if (typeof grantId !== "string" || !ANTHROPIC_RESET_GRANT_ID_RE.test(grantId)) return scopedFail(400, "invalid_grant_id");
197
+ if (typeof operationId !== "string" || !UUID_V4_RE.test(operationId)) return scopedFail(400, "invalid_operation_id");
198
+ if (typeof body.accountId !== "string") return scopedFail(400, "no_account");
144
199
  const accountId = resolveAccountId(deps, body.accountId);
145
- if (!accountId) return fail(ctx, 400, "no_account");
200
+ if (!accountId) return scopedFail(400, "no_account");
146
201
 
147
202
  const accessToken = await tokenFor(deps, accountId);
148
- if (!accessToken) return fail(ctx, 401, "auth_failed");
203
+ if (!accessToken || deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
149
204
  let organizationUuid: string;
150
205
  try {
151
206
  organizationUuid = await fetchAnthropicOrganizationUuid({ accessToken, fetchFn: deps.fetchFn });
152
207
  } catch (error) {
153
- return readFailure(ctx, error);
208
+ return readFailure(ctx, error, operationReceipt);
154
209
  }
210
+ if (deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
155
211
  const ledger = { journalPath: deps.journalPath, now: deps.now?.() };
156
212
 
157
213
  // A new operation must pass the spend gate on a fresh read. A same-id retry of
@@ -161,39 +217,40 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
161
217
  try {
162
218
  known = anthropicResetOperationExists(operationId, ledger);
163
219
  } catch (error) {
164
- return ledgerFailure(ctx, error);
220
+ return ledgerFailure(ctx, error, operationReceipt);
165
221
  }
166
222
  if (!known) {
167
223
  let status;
168
224
  try {
169
225
  status = await fetchAnthropicResetGrantStatus({ accessToken, fetchFn: deps.fetchFn });
170
226
  } catch (error) {
171
- return readFailure(ctx, error);
227
+ return readFailure(ctx, error, operationReceipt);
172
228
  }
173
229
  const blocker = anthropicResetGrantBlocker(status, grantId);
174
- if (blocker) return fail(ctx, 409, "grant_not_usable", { reason: blocker });
230
+ if (blocker) return scopedFail(409, "grant_not_usable", { reason: blocker });
175
231
  }
176
232
 
177
233
  let begin;
234
+ if (deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
178
235
  try {
179
236
  begin = beginAnthropicResetOperation(
180
237
  { operationId, accountId, grantId, orgDigest: anthropicOrgDigest(organizationUuid) },
181
238
  { journalPath: deps.journalPath, now: deps.now?.() },
182
239
  );
183
240
  } catch (error) {
184
- return ledgerFailure(ctx, error);
241
+ return ledgerFailure(ctx, error, operationReceipt);
185
242
  }
186
243
  switch (begin.kind) {
187
244
  case "replay":
188
245
  return jsonResponse(
189
- { code: begin.code, replayed: true, resetsLeft: begin.resetsLeft, accountId, grantId, operationId },
246
+ { provider, code: begin.code, replayed: true, resetsLeft: begin.resetsLeft, accountId, grantId, operationId },
190
247
  200, ctx.req, ctx.config,
191
248
  );
192
- case "identity-mismatch": return fail(ctx, 409, "operation_identity_mismatch");
193
- case "in-flight": return fail(ctx, 409, "in_flight");
194
- case "expired": return fail(ctx, 409, "unknown_outcome_expired");
195
- case "unresolved-prior": return fail(ctx, 409, "unresolved_prior_operation", { pendingOperationId: begin.operationId });
196
- case "capacity": return fail(ctx, 503, "ledger_capacity");
249
+ case "identity-mismatch": return scopedFail(409, "operation_identity_mismatch");
250
+ case "in-flight": return scopedFail(409, "in_flight");
251
+ case "expired": return scopedFail(409, "unknown_outcome_expired");
252
+ case "unresolved-prior": return scopedFail(409, "unresolved_prior_operation", { pendingOperationId: begin.operationId });
253
+ case "capacity": return scopedFail(503, "ledger_capacity");
197
254
  case "execute": break;
198
255
  }
199
256
 
@@ -206,7 +263,7 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
206
263
  try {
207
264
  releaseAnthropicResetLease(operationId, { journalPath: deps.journalPath, now: deps.now?.() });
208
265
  } catch { /* the lease expires on its own */ }
209
- return fail(ctx, 502, "unknown_outcome", { operationId, grantId });
266
+ return scopedFail(502, "unknown_outcome", { operationId, grantId, accountId });
210
267
  }
211
268
  let settled;
212
269
  try {
@@ -216,13 +273,14 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
216
273
  );
217
274
  } catch {
218
275
  // Fail closed: the caller must not read this as a durable result.
219
- return fail(ctx, 500, "journal_write_failed", { operationId, grantId });
276
+ return scopedFail(500, "journal_write_failed", { operationId, grantId, accountId });
220
277
  }
221
278
  // Report what the journal holds. An overlapping same-id attempt may have
222
279
  // settled first; its answer is the canonical one.
223
280
  const replayed = settled.code !== answer.code || settled.resetsLeft !== answer.resetsLeft;
224
281
  return jsonResponse(
225
282
  {
283
+ provider,
226
284
  code: settled.code,
227
285
  replayed,
228
286
  resetsLeft: settled.resetsLeft,
@@ -237,12 +295,13 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
237
295
 
238
296
  export async function handleAnthropicResetGrantRoutes(
239
297
  ctx: ManagementContext,
240
- deps: AnthropicResetGrantRouteDeps = defaultDeps,
298
+ deps?: AnthropicResetGrantRouteDeps,
241
299
  ): Promise<Response | null> {
242
300
  const { pathname } = ctx.url;
243
301
  if (pathname === "/api/anthropic/reset-grants") {
244
302
  if (ctx.req.method !== "GET") return fail(ctx, 405, "method_not_allowed");
245
- return handleRead(ctx, deps);
303
+ const selected = selectDeps(ctx, ctx.url.searchParams.get("provider"), deps);
304
+ return selected ? handleRead(ctx, selected) : fail(ctx, 400, "invalid_provider");
246
305
  }
247
306
  if (pathname === "/api/anthropic/reset-grants/consume") {
248
307
  if (ctx.req.method !== "POST") return fail(ctx, 405, "method_not_allowed");