@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
@@ -19,17 +19,11 @@
19
19
  * No token, account id or body content is logged or returned in an error message.
20
20
  */
21
21
  import type { OAuthAccessSnapshot } from "../oauth";
22
- import {
23
- commitAnthropicSelectionRouting,
24
- captureAnthropicManualSelectionGeneration,
25
- getAnthropicPoolAccessSnapshot,
26
- getAnthropicAccountHealthSnapshot,
27
- resolveAnthropicDispatchAccountId,
28
- resolveAnthropicAccountForSession,
29
- getEligibleAnthropicAccounts,
30
- isAnthropicAccountPoolEnabled,
31
- } from "../oauth/anthropic-routing";
32
- import { resolveAnthropicModelRoute, routeCandidates, type AnthropicRouteDecision } from "../oauth/anthropic-model-routes";
22
+ import { anthropicRoutingFor } from "../oauth/anthropic-routing";
23
+ import { resolveAnthropicModelRouteForInstance, routeCandidates, type AnthropicRouteDecision } from "../oauth/anthropic-model-routes";
24
+ import { configuredAnthropicInstance, type AnthropicInstanceId } from "../providers/anthropic-instance";
25
+ import { resolveAnthropicMessagesUrl } from "../adapters/anthropic";
26
+ import { routedProviderConfig } from "../router";
33
27
  import {
34
28
  captureOAuthAccountSelection,
35
29
  commitOAuthAccountSelection,
@@ -40,7 +34,6 @@ import type { TranslatorBudget } from "../lib/translator-budget";
40
34
  import type { OcxConfig } from "../types";
41
35
  import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite";
42
36
 
43
- const PROVIDER = "anthropic";
44
37
  const MAX_SELECTION_ATTEMPTS = 3;
45
38
 
46
39
  type Selection = NonNullable<ReturnType<typeof captureOAuthAccountSelection>>;
@@ -52,6 +45,9 @@ function isRec(value: unknown): value is Rec {
52
45
 
53
46
  /** The committed selection and the credential snapshot one native request is served with. */
54
47
  export interface NativeOAuthBinding {
48
+ readonly instance: AnthropicInstanceId;
49
+ /** Config-authorized physical destination, captured before any credential await. */
50
+ readonly routeTarget: string;
55
51
  readonly selection: Selection;
56
52
  readonly snapshot: OAuthAccessSnapshot;
57
53
  /** Anthropic account UUID from the same stored generation, distinct from snapshot.accountId. */
@@ -74,17 +70,43 @@ export class NativeOAuthSelectionChangedError extends Error {
74
70
  /** Invalid operator route configuration remains a request refusal, not an auth failure. */
75
71
  export class NativeOAuthModelRouteError extends Error {}
76
72
 
77
- function currentRoute(config: OcxConfig, model?: string): AnthropicRouteDecision | null {
78
- const resolved = model ? resolveAnthropicModelRoute(config, model) : { decision: null };
73
+ /**
74
+ * Current configured ownership and target; a revoked B never resolves orphan credentials.
75
+ *
76
+ * Adapter, auth mode and destination are read from the routed provider, the same normalization the
77
+ * settled route used to compute its target. Routing canonicalizes the primary row's adapter and auth
78
+ * mode from the registry, so a raw-field comparison would refuse an existing Pool 1 row the route
79
+ * already accepted. B's own raw-row shape is still enforced through `configuredAnthropicInstance`.
80
+ */
81
+ function nativeOAuthRouteTarget(instance: AnthropicInstanceId, config: OcxConfig): string {
82
+ const provider = config.providers[instance];
83
+ if (configuredAnthropicInstance(config, instance) !== instance || !provider || provider.disabled === true) {
84
+ throw new NativeOAuthSelectionChangedError();
85
+ }
86
+ let routed: ReturnType<typeof routedProviderConfig>;
87
+ try {
88
+ routed = routedProviderConfig(instance, provider);
89
+ } catch {
90
+ // A destination the router now refuses is a moved route, not a different failure class.
91
+ throw new NativeOAuthSelectionChangedError();
92
+ }
93
+ if (routed.authMode !== "oauth" || routed.adapter !== "anthropic") throw new NativeOAuthSelectionChangedError();
94
+ return resolveAnthropicMessagesUrl(routed);
95
+ }
96
+
97
+ function currentRoute(instance: AnthropicInstanceId, config: OcxConfig, model?: string): AnthropicRouteDecision | null {
98
+ const resolved = model ? resolveAnthropicModelRouteForInstance(instance, config, model) : { decision: null };
79
99
  if (resolved.error) throw new NativeOAuthModelRouteError(`Invalid Anthropic model routes: ${resolved.error}`);
80
100
  return resolved.decision ? { ...resolved.decision, accounts: [...resolved.decision.accounts] } : null;
81
101
  }
82
102
 
83
- function routeIsCurrent(config: OcxConfig, model: string | undefined, decision: AnthropicRouteDecision | null): boolean {
84
- return JSON.stringify(currentRoute(config, model)) === JSON.stringify(decision);
103
+ function routeIsCurrent(instance: AnthropicInstanceId, config: OcxConfig, model: string | undefined, decision: AnthropicRouteDecision | null): boolean {
104
+ return JSON.stringify(currentRoute(instance, config, model)) === JSON.stringify(decision);
85
105
  }
86
106
 
87
107
  export interface NativeOAuthBindingOptions {
108
+ /** Settled route destination; retries may never authorize a replacement destination. */
109
+ routeTarget?: string;
88
110
  sessionKey?: string | null;
89
111
  model?: string;
90
112
  /** An account proposed by the shared pre-output refusal recovery policy. */
@@ -94,21 +116,34 @@ export interface NativeOAuthBindingOptions {
94
116
  expectedRecoveryRouteDecision?: AnthropicRouteDecision | null;
95
117
  }
96
118
 
119
+ /** Compatibility entrypoint: the original instance only. */
120
+ export function resolveNativeOAuthBinding(config: OcxConfig, options: NativeOAuthBindingOptions = {}): Promise<NativeOAuthBinding> {
121
+ return resolveNativeOAuthBindingForInstance("anthropic", config, options);
122
+ }
123
+
97
124
  /** Resolve the shared pool selector and commit its exact credential generation for dispatch. */
98
- export async function resolveNativeOAuthBinding(
125
+ export async function resolveNativeOAuthBindingForInstance(
126
+ instance: AnthropicInstanceId,
99
127
  config: OcxConfig,
100
128
  options: NativeOAuthBindingOptions = {},
101
129
  ): Promise<NativeOAuthBinding> {
130
+ const routeTarget = options.routeTarget ?? nativeOAuthRouteTarget(instance, config);
131
+ const routing = anthropicRoutingFor(instance);
132
+ const assertOwner = () => {
133
+ if (nativeOAuthRouteTarget(instance, config) !== routeTarget) throw new NativeOAuthSelectionChangedError();
134
+ };
102
135
  const sessionKey = options.sessionKey ?? null;
103
136
  const model = options.model;
104
137
  for (let attempt = 0; attempt < MAX_SELECTION_ATTEMPTS; attempt++) {
105
- const routeDecision = currentRoute(config, model);
106
- const manualSelectionGeneration = captureAnthropicManualSelectionGeneration();
107
- const selection = captureOAuthAccountSelection(PROVIDER);
138
+ assertOwner();
139
+ const routeDecision = currentRoute(instance, config, model);
140
+ const manualSelectionGeneration = routing.captureAnthropicManualSelectionGeneration();
141
+ const selection = captureOAuthAccountSelection(instance);
108
142
  // Always ask admission first so pause/login/cooldown retains its typed local refusal.
109
- const admittedId = await resolveAnthropicDispatchAccountId(config, sessionKey, routeDecision, model);
110
- if (!routeIsCurrent(config, model, routeDecision)) continue;
111
- const proposed = resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model);
143
+ const admittedId = await routing.resolveAnthropicDispatchAccountId(config, sessionKey, routeDecision, model);
144
+ assertOwner();
145
+ if (!routeIsCurrent(instance, config, model, routeDecision)) continue;
146
+ const proposed = routing.resolveAnthropicAccountForSession(sessionKey, config, Date.now(), routeDecision, model);
112
147
  // A lost selection commit discards a recovery proposal: a newer manual/policy choice wins.
113
148
  const recoverySelectionMatches = !!options.expectedRecoverySelection
114
149
  && selection?.accountId === options.expectedRecoverySelection.accountId
@@ -118,27 +153,30 @@ export async function resolveNativeOAuthBinding(
118
153
  const accountId = attempt === 0 && options.candidateAccountId && recoverySelectionMatches && recoveryRouteMatches
119
154
  ? options.candidateAccountId : admittedId;
120
155
  if (!selection) continue;
121
- if (!routeCandidates(getEligibleAnthropicAccounts(Date.now(), model), routeDecision).includes(accountId)) {
156
+ if (!routeCandidates(routing.getEligibleAnthropicAccounts(Date.now(), model), routeDecision).includes(accountId)) {
122
157
  throw new NativeOAuthSelectionChangedError();
123
158
  }
124
- const candidate = await getAnthropicPoolAccessSnapshot(accountId);
125
- if (!routeIsCurrent(config, model, routeDecision)) continue;
126
- const committed = await commitOAuthAccountSelection(PROVIDER, candidate.accountId, {
159
+ const candidate = await routing.getAnthropicPoolAccessSnapshot(accountId);
160
+ assertOwner();
161
+ if (candidate.provider !== instance) throw new NativeOAuthSelectionChangedError();
162
+ if (!routeIsCurrent(instance, config, model, routeDecision)) continue;
163
+ const committed = await commitOAuthAccountSelection(instance, candidate.accountId, {
127
164
  expectedSelection: selection,
128
165
  expectedCredentialGeneration: candidate.generation,
129
166
  requireUsableAccount: true,
130
167
  });
168
+ assertOwner();
131
169
  if (committed) {
132
- if (!routeIsCurrent(config, model, routeDecision)
133
- || manualSelectionGeneration !== captureAnthropicManualSelectionGeneration()) continue;
134
- if (!commitAnthropicSelectionRouting(candidate.accountId, selection, committed, {
170
+ if (!routeIsCurrent(instance, config, model, routeDecision)
171
+ || manualSelectionGeneration !== routing.captureAnthropicManualSelectionGeneration()) continue;
172
+ if (!routing.commitAnthropicSelectionRouting(candidate.accountId, selection, committed, {
135
173
  config, sessionKey, model, routeDecision,
136
174
  reason: accountId === proposed.accountId ? proposed.reason : undefined,
137
175
  expectedCredentialGeneration: candidate.generation,
138
176
  })) continue;
139
- const row = getAccountCredentialWithStatus(PROVIDER, candidate.accountId);
140
- if (!row || credentialGeneration(row.credential) !== candidate.generation) continue;
141
- const binding = { selection: committed, snapshot: candidate, providerAccountUuid: row.credential.accountId, routeDecision, sessionKey, model, config, manualSelectionGeneration };
177
+ const row = getAccountCredentialWithStatus(instance, candidate.accountId);
178
+ if (!row || row.credential.access !== candidate.accessToken || credentialGeneration(row.credential) !== candidate.generation) continue;
179
+ const binding: NativeOAuthBinding = { instance, routeTarget, selection: committed, snapshot: candidate, providerAccountUuid: row.credential.accountId, routeDecision, sessionKey, model, config, manualSelectionGeneration };
142
180
  if (nativeOAuthBindingIsCurrent(binding)) return binding;
143
181
  }
144
182
  }
@@ -150,12 +188,18 @@ export async function resolveNativeOAuthBinding(
150
188
  * selection, plus the same usable credential generation. Checked before every physical send.
151
189
  */
152
190
  export function nativeOAuthBindingIsCurrent(binding: NativeOAuthBinding): boolean {
153
- if (binding.manualSelectionGeneration !== captureAnthropicManualSelectionGeneration()
154
- || !routeIsCurrent(binding.config, binding.model, binding.routeDecision)) return false;
155
- const selected = captureOAuthAccountSelection(PROVIDER);
156
- const row = getAccountCredentialWithStatus(PROVIDER, binding.snapshot.accountId);
157
- const sessionRoute = isAnthropicAccountPoolEnabled(binding.config) && binding.sessionKey
158
- ? resolveAnthropicAccountForSession(binding.sessionKey, binding.config, Date.now(), binding.routeDecision, binding.model) : null;
191
+ const { instance } = binding;
192
+ const routing = anthropicRoutingFor(instance);
193
+ try {
194
+ if (nativeOAuthRouteTarget(instance, binding.config) !== binding.routeTarget) return false;
195
+ } catch { return false; }
196
+ if (binding.snapshot.provider !== instance) return false;
197
+ if (binding.manualSelectionGeneration !== routing.captureAnthropicManualSelectionGeneration()
198
+ || !routeIsCurrent(instance, binding.config, binding.model, binding.routeDecision)) return false;
199
+ const selected = captureOAuthAccountSelection(instance);
200
+ const row = getAccountCredentialWithStatus(instance, binding.snapshot.accountId);
201
+ const sessionRoute = routing.isAnthropicAccountPoolEnabled(binding.config) && binding.sessionKey
202
+ ? routing.resolveAnthropicAccountForSession(binding.sessionKey, binding.config, Date.now(), binding.routeDecision, binding.model) : null;
159
203
  // Another conversation may move the automatic active pointer without revoking this
160
204
  // session's affinity. Manual selection clears affinity and precedes it in the selector.
161
205
  const selectionCurrent = sessionRoute?.reason === "affinity"
@@ -163,8 +207,9 @@ export function nativeOAuthBindingIsCurrent(binding: NativeOAuthBinding): boolea
163
207
  || (selected?.accountId === binding.selection.accountId && selected?.revision === binding.selection.revision);
164
208
  return selectionCurrent
165
209
  && !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now()
166
- && !getAnthropicAccountHealthSnapshot(binding.snapshot.accountId)
167
- && routeCandidates(getEligibleAnthropicAccounts(Date.now(), binding.model), binding.routeDecision).includes(binding.snapshot.accountId)
210
+ && !routing.getAnthropicAccountHealthSnapshot(binding.snapshot.accountId)
211
+ && routeCandidates(routing.getEligibleAnthropicAccounts(Date.now(), binding.model), binding.routeDecision).includes(binding.snapshot.accountId)
212
+ && row.credential.access === binding.snapshot.accessToken
168
213
  && credentialGeneration(row.credential) === binding.snapshot.generation
169
214
  && row.credential.accountId === binding.providerAccountUuid;
170
215
  }
@@ -0,0 +1,39 @@
1
+ /** Pure caller-forward exclusion shared by Messages ingress and protocol preview. */
2
+ import { resolveInboundModel } from "../claude/inbound-model-options";
3
+ import { routeConcreteModel } from "../router";
4
+ import { configuredAnthropicInstance } from "../providers/anthropic-instance";
5
+ import { hasOwnProvider } from "../config/provider-name";
6
+ import type { OcxConfig } from "../types";
7
+
8
+ export function messagesSelectorTargetsSecondaryInstance(
9
+ config: OcxConfig,
10
+ model: string,
11
+ cc: OcxConfig["claudeCode"] = config.claudeCode,
12
+ ): boolean {
13
+ const selector = resolveInboundModel(model, cc);
14
+ const slash = selector.indexOf("/");
15
+ if (slash <= 0) return false;
16
+ const qualifier = selector.slice(0, slash);
17
+ // An orphan or unmarked B selector must also never forward a caller's credential.
18
+ if (qualifier === "anthropic2") return true;
19
+ // Provider keys are case-sensitive and outrank aliases, including when disabled.
20
+ if (hasOwnProvider(config.providers, qualifier)) return false;
21
+ try {
22
+ // Concrete resolution handles configured provider aliases without picking a combo/account.
23
+ return routeConcreteModel(config, selector).providerName === "anthropic2";
24
+ } catch {
25
+ return config.providers.anthropic2?.alias?.trim().toLowerCase() === qualifier.toLowerCase();
26
+ }
27
+ }
28
+
29
+ /** A missing B row must not fall through the router's generic default-provider path. */
30
+ export function messagesSecondaryInstanceUnavailable(
31
+ config: OcxConfig,
32
+ model: string,
33
+ cc: OcxConfig["claudeCode"] = config.claudeCode,
34
+ ): boolean {
35
+ if (!messagesSelectorTargetsSecondaryInstance(config, model, cc)) return false;
36
+ const provider = config.providers.anthropic2;
37
+ return !provider || provider.disabled === true
38
+ || provider.authMode === "oauth" && configuredAnthropicInstance(config, "anthropic2") !== "anthropic2";
39
+ }
@@ -1,3 +1,5 @@
1
+ import { createPhysicalSendReporter } from "../lib/request-execution-budget";
2
+ import { createInferenceSendBudget } from "./inference/context";
1
3
  /**
2
4
  * Managed native Messages lane (PF-08, behind `protocols.rollout.managedMessagesNative`).
3
5
  *
@@ -17,16 +19,18 @@
17
19
  *
18
20
  * Loaded lazily by `claude-messages.ts` only when the switch is on and a route is eligible.
19
21
  */
20
- import { resolveAnthropicModelRoute } from "../oauth/anthropic-model-routes";
21
- import { bindAnthropicRefusalCredential, rotateAnthropicAccountOnResponse } from "../oauth/anthropic-account-refusal";
22
- import { claimAnthropicFamilyRevalidation } from "../oauth/anthropic-model-quota";
22
+ import { resolveAnthropicModelRouteForInstance } from "../oauth/anthropic-model-routes";
23
+ import { bindAnthropicRefusalCredentialForSend, rotateAnthropicAccountOnResponseForInstance } from "../oauth/anthropic-account-refusal";
24
+ import { captureAnthropicPhysicalSendOwnership, anthropicPhysicalSendOwnershipIsCurrent,
25
+ type AnthropicPhysicalSendOwnership } from "../oauth/anthropic-send-ownership";
26
+ import { anthropicModelQuotaFor } from "../oauth/anthropic-model-quota";
23
27
  import { captureConfigGeneration } from "../lib/state-store-sweeper";
24
- import { recordAnthropicAccountQuotaFromHeaders } from "../providers/quota";
28
+ import { recordAnthropicAccountQuotaFromHeadersForInstance } from "../providers/quota";
25
29
  import { credentialGeneration, getAccountCredentialWithStatus } from "../oauth/store";
26
30
  import { ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST } from "../oauth/anthropic-routing";
27
31
  import { enforceAnthropicImageLimits } from "../adapters/anthropic-image-guard";
28
32
  import { normalizeAnthropicImages } from "../adapters/anthropic-image-normalize";
29
- import { formatAnthropicErrorBody } from "../adapters/anthropic";
33
+ import { formatAnthropicErrorBody, resolveAnthropicMessagesUrl } from "../adapters/anthropic";
30
34
  import {
31
35
  anthropicMessagesNativeWireBody,
32
36
  buildAnthropicMessagesPassthroughRequest,
@@ -43,6 +47,7 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/t
43
47
  import {
44
48
  applyReplayRefusalClientHeaders,
45
49
  applyUpstreamRecoveryInit,
50
+ SendBudgetExhaustedError,
46
51
  fetchWithResetRetry,
47
52
  fetchWithTransientRetry,
48
53
  isNonReplayableResponse,
@@ -67,12 +72,13 @@ import {
67
72
  } from "../providers/key-failover";
68
73
  import { stampApiKeyAccountLabel } from "../providers/label";
69
74
  import { OAuthAccountPausedError, OAuthLoginRequiredError, publicOAuthAuthenticationErrorMessage } from "../oauth";
70
- import { AnthropicAccountCooldownError, formatAnthropicProviderForLog, hasAnthropicFailoverQuorum } from "../oauth/anthropic-routing";
75
+ import { AnthropicAccountCooldownError, formatAnthropicProviderForLog, anthropicRoutingFor } from "../oauth/anthropic-routing";
76
+ import { configuredAnthropicInstance } from "../providers/anthropic-instance";
71
77
  import { resolveProtocolSettings } from "../protocols/settings";
72
78
  import { addProtocolEntryReason, markProtocolBlocked } from "../protocols/trace";
73
79
  import type { OcxProviderTransport } from "../providers/xai-transport";
74
80
  import { preservesPhysicalComboProvider, resolveComboId } from "../combos";
75
- import { captureRouteStaticPolicy, routeModel, type RouteResult } from "../router";
81
+ import { captureRouteStaticPolicy, routedProviderConfig, routeModel, type RouteResult } from "../router";
76
82
  import { POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile";
77
83
  import type { AnthropicClientIdentity } from "../adapters/anthropic/client-identity";
78
84
  import type { OcxConfig, OcxProviderConfig, OcxUsage } from "../types";
@@ -88,11 +94,12 @@ import { beginInferenceAttempt } from "./inference/attempt";
88
94
  import { createFinalRequestLog, type FinalRequestLogMeta } from "./inference/final-log";
89
95
  import { registerTurn, unregisterTurn } from "./lifecycle";
90
96
  import { nativeMessagesDeclineReason, type NativeMessagesSelector } from "./messages-native-eligibility";
97
+ import { messagesSecondaryInstanceUnavailable } from "./messages-native-selector";
91
98
  import {
92
99
  nativeOAuthBindingIsCurrent,
93
100
  NativeOAuthSelectionChangedError,
94
101
  NativeOAuthModelRouteError,
95
- resolveNativeOAuthBinding,
102
+ resolveNativeOAuthBindingForInstance,
96
103
  restoreOAuthToolNamesInMessage,
97
104
  restoreOAuthToolNamesInSse,
98
105
  type NativeOAuthBinding,
@@ -300,10 +307,12 @@ async function prepareNativeBody(body: Rec, signal: AbortSignal): Promise<void>
300
307
  */
301
308
  export async function handleNativeMessages(options: HandleNativeMessagesOptions): Promise<Response> {
302
309
  const { req, config, logCtx, logIds, route, body, requestedModel, translatorBudget } = options;
310
+ const nativeInstance = configuredAnthropicInstance(config, route.providerName);
303
311
  const requestedStream = body.stream === true;
304
312
  logCtx.inboundProtocol = "messages";
305
313
  logCtx.model = route.modelId;
306
314
  logCtx.provider = route.providerName;
315
+ logCtx.spendPoolId = route.providerName;
307
316
  logCtx.providerAdapter = route.provider.adapter;
308
317
  logCtx.requestedModel = requestedModel;
309
318
  if (route.routeReason === "model-alias" || route.modelId !== requestedModel) logCtx.requestedAlias = requestedModel;
@@ -353,11 +362,18 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
353
362
  return null;
354
363
  };
355
364
 
365
+ if (route.provider.authMode === "oauth" && !nativeInstance) {
366
+ return fail(401, "Configured Anthropic OAuth instance is unavailable", "authentication_error");
367
+ }
368
+
356
369
  const toolScopeDenial = nativeMessagesToolScopeDenial(options.modelScope, route.providerName, requestedModel, body);
357
370
  if (toolScopeDenial) {
358
371
  finishLog(403, toolScopeDenial.message);
359
372
  return admissionModelDeniedResponse(toolScopeDenial);
360
373
  }
374
+ let routeTarget: string;
375
+ try { routeTarget = resolveAnthropicMessagesUrl(route.provider); }
376
+ catch { return fail(400, "Invalid Anthropic Messages destination", "invalid_request_error"); }
361
377
 
362
378
  try {
363
379
  await prepareNativeBody(body, req.signal);
@@ -391,7 +407,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
391
407
  const oauthProvider = (binding: NativeOAuthBinding): OcxProviderConfig => ({ ...route.provider, apiKey: binding.snapshot.accessToken });
392
408
  if (route.provider.authMode === "oauth") {
393
409
  try {
394
- oauthBinding = await resolveNativeOAuthBinding(config, { sessionKey: options.sessionKey, model: route.modelId });
410
+ oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget, sessionKey: options.sessionKey, model: route.modelId });
395
411
  } catch (error) {
396
412
  cleanupAbort();
397
413
  upstream.abort();
@@ -411,6 +427,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
411
427
  stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
412
428
  if (oauthBinding) logCtx.provider = formatAnthropicProviderForLog(route.providerName, oauthBinding.snapshot.accountId, config);
413
429
  const spendTracker = attachRequestSpendTracker(req, logCtx);
430
+ const physicalBudget = createInferenceSendBudget(req, logCtx);
414
431
  let activeRequest: AnthropicMessagesPassthroughRequest;
415
432
  let retainedRequestBytes = 0;
416
433
  const releaseRetainedRequest = () => {
@@ -473,6 +490,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
473
490
  : Number.POSITIVE_INFINITY;
474
491
  const transientSendAvailable = (): boolean => remainingTransientSends() > 0;
475
492
  const selector: NativeMessagesSelector = options.selector ?? {};
493
+ const nativeOAuthRouteIsCurrent = (binding: NativeOAuthBinding): boolean => {
494
+ const provider = config.providers[binding.instance];
495
+ if (configuredAnthropicInstance(config, binding.instance) !== binding.instance || !provider) return false;
496
+ // Same normalization as the bound target (nativeOAuthRouteTarget): compare routed destinations.
497
+ let routed: OcxProviderConfig;
498
+ try { routed = routedProviderConfig(binding.instance, provider); } catch { return false; }
499
+ return resolveAnthropicMessagesUrl(routed) === routeTarget
500
+ && nativeMessagesDeclineReason({ ...route, provider: routed }, body, config, selector) === undefined;
501
+ };
476
502
 
477
503
  const send = async (recovery?: "rate-limit-429" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
478
504
  const remaining = remainingTransientSends();
@@ -493,12 +519,13 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
493
519
  modelId: route.modelId,
494
520
  dispatchOverride: async (_input, init, execute) => {
495
521
  if (oauthBinding) {
522
+ if (!nativeOAuthRouteIsCurrent(oauthBinding)) throw new NativeOAuthSelectionChangedError();
496
523
  // The OAuth twin of the key check below: re-resolve through the same selection
497
524
  // owner when the committed account or its credential moved since the build.
498
525
  for (let attempt = 0; !nativeOAuthBindingIsCurrent(oauthBinding); attempt++) {
499
526
  if (attempt >= 3) throw new NativeOAuthSelectionChangedError();
500
527
  try {
501
- oauthBinding = await resolveNativeOAuthBinding(config, { sessionKey: options.sessionKey, model: route.modelId });
528
+ oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget, sessionKey: options.sessionKey, model: route.modelId });
502
529
  } catch (error) {
503
530
  if (error instanceof OAuthAccountPausedError || error instanceof OAuthLoginRequiredError
504
531
  || error instanceof AnthropicAccountCooldownError || error instanceof NativeOAuthModelRouteError) throw error;
@@ -519,31 +546,58 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
519
546
  const headers = new Headers(wire.headers);
520
547
  const encoding = new Headers(init.headers).get("accept-encoding");
521
548
  if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
522
- const snapshot = oauthBinding?.snapshot;
523
- const providerAccountUuid = oauthBinding?.providerAccountUuid;
549
+ const sendingBinding = oauthBinding;
550
+ const snapshot = sendingBinding?.snapshot;
551
+ const providerAccountUuid = sendingBinding?.providerAccountUuid;
524
552
  const writerGeneration = snapshot ? captureConfigGeneration() : 0;
525
- const ownsBearer = snapshot && headers.get("authorization") === `Bearer ${snapshot.accessToken}` && !headers.has("x-api-key");
526
- const releaseFamily = snapshot ? claimAnthropicFamilyRevalidation(snapshot.accountId, route.modelId) : () => {};
553
+ const ownsBearer = snapshot && snapshot.provider === nativeInstance
554
+ && headers.get("authorization") === `Bearer ${snapshot.accessToken}` && !headers.has("x-api-key");
555
+ if (oauthBinding && (!ownsBearer || wire.url !== oauthBinding.routeTarget
556
+ || !nativeOAuthRouteIsCurrent(oauthBinding) || !nativeOAuthBindingIsCurrent(oauthBinding))) {
557
+ throw new NativeOAuthSelectionChangedError();
558
+ }
559
+ const releaseFamily = snapshot && oauthBinding ? anthropicModelQuotaFor(oauthBinding.instance).claimAnthropicFamilyRevalidation(snapshot.accountId, route.modelId) : () => {};
527
560
  if (!releaseFamily) throw new AnthropicAccountCooldownError(1);
528
561
  let dispatched: Response;
562
+ const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
563
+ poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel,
564
+ }));
565
+ let sendOwner: AnthropicPhysicalSendOwnership | null = null;
529
566
  try {
530
567
  if (init.signal?.aborted) throw init.signal.reason;
531
- if (!spendTracker.charge()) throw new NativeMessagesSpendRefusal();
568
+ // Capture before either accounting path so this request retains its start policy.
569
+ if (spendReport.beforeSend && !spendReport.beforeSend()) {
570
+ if (spendTracker.refusals) throw new NativeMessagesSpendRefusal();
571
+ throw new SendBudgetExhaustedError();
572
+ }
573
+ if (!physicalBudget.spendEnforced && !spendTracker.charge()) throw new NativeMessagesSpendRefusal();
532
574
  noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
575
+ // Last synchronous ownership reservation before the physical fetch. Retain this
576
+ // exact incarnation across its await; a returned response can never reserve one.
577
+ sendOwner = snapshot ? captureAnthropicPhysicalSendOwnership(snapshot) : null;
578
+ if (snapshot && !sendOwner) throw new NativeOAuthSelectionChangedError();
533
579
  dispatched = await sendWithConnectionPolicy(
534
580
  (activeProvider as OcxProviderTransport).fetch ?? execute,
535
581
  wire.url,
536
582
  applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery),
537
583
  { providerName: route.providerName, provider: activeProvider },
538
584
  );
539
- } finally { releaseFamily(); }
540
- if (ownsBearer && snapshot) {
585
+ } finally {
586
+ try { if (physicalBudget.spendEnforced) spendReport(1); }
587
+ finally {
588
+ try { spendReport.close?.(); }
589
+ finally { releaseFamily(); }
590
+ }
591
+ }
592
+ if (ownsBearer && snapshot && sendOwner && sendingBinding) {
541
593
  try {
542
- const current = getAccountCredentialWithStatus("anthropic", snapshot.accountId);
543
- if (current && !current.needsReauth && credentialGeneration(current.credential) === snapshot.generation
594
+ const current = getAccountCredentialWithStatus(snapshot.provider, snapshot.accountId);
595
+ if (anthropicPhysicalSendOwnershipIsCurrent(sendOwner) && nativeOAuthRouteIsCurrent(sendingBinding)
596
+ && current && !current.needsReauth && current.credential.access === snapshot.accessToken
597
+ && credentialGeneration(current.credential) === snapshot.generation
544
598
  && current.credential.accountId === providerAccountUuid) {
545
- bindAnthropicRefusalCredential(dispatched, snapshot, providerAccountUuid);
546
- recordAnthropicAccountQuotaFromHeaders(snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId);
599
+ bindAnthropicRefusalCredentialForSend(dispatched, sendOwner, providerAccountUuid);
600
+ recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId);
547
601
  }
548
602
  } catch { /* Passive observation must not fail the response. */ }
549
603
  }
@@ -622,12 +676,13 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
622
676
  const sendingBinding = oauthBinding;
623
677
  const expectedRecoverySelection = sendingBinding.selection;
624
678
  triedAccountIds.add(sendingBinding.snapshot.accountId);
625
- const nextAccountId = await rotateAnthropicAccountOnResponse(response, {
679
+ const nextAccountId = await rotateAnthropicAccountOnResponseForInstance(sendingBinding.instance, response, {
626
680
  config, accountId: sendingBinding.snapshot.accountId, model: route.modelId,
627
681
  sessionKey: sendingBinding.sessionKey, decision: sendingBinding.routeDecision,
628
682
  excludedAccountIds: triedAccountIds,
629
683
  currentDecision: () => {
630
- const resolved = resolveAnthropicModelRoute(config, route.modelId);
684
+ if (!nativeOAuthRouteIsCurrent(sendingBinding)) throw new NativeOAuthSelectionChangedError();
685
+ const resolved = resolveAnthropicModelRouteForInstance(sendingBinding.instance, config, route.modelId);
631
686
  if (resolved.error) throw new NativeOAuthModelRouteError(`Invalid Anthropic model routes: ${resolved.error}`);
632
687
  return resolved.decision;
633
688
  },
@@ -637,7 +692,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
637
692
  if (!nextAccountId) break;
638
693
  const recovery = response.status === 403 ? "oauth-account-403" : "rate-limit-429";
639
694
  discard(response);
640
- oauthBinding = await resolveNativeOAuthBinding(config, {
695
+ oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget,
641
696
  sessionKey: options.sessionKey, model: route.modelId, candidateAccountId: nextAccountId,
642
697
  expectedRecoverySelection, expectedRecoveryRouteDecision: sendingBinding.routeDecision,
643
698
  });
@@ -652,6 +707,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
652
707
  upstream.abort();
653
708
  if (req.signal.aborted) return fail(499, "Client cancelled request", "api_error");
654
709
  const sendError = error instanceof UpstreamRetryEvidenceError ? error.cause : error;
710
+ if (sendError instanceof SendBudgetExhaustedError) return fail(429, sendError.message, "send_budget_exhausted", "send_budget_exhausted");
655
711
  if (sendError instanceof NativeMessagesSpendRefusal) {
656
712
  const refusal = workflowRefusalResponse("workflow-spend-exhausted", logCtx);
657
713
  finishLog(429);
@@ -865,6 +921,7 @@ export function nativeMessagesCountBody(
865
921
  ): Rec | undefined {
866
922
  if (typeof body.model !== "string") return undefined;
867
923
  try {
924
+ if (messagesSecondaryInstanceUnavailable(config, body.model, cc)) return undefined;
868
925
  const selectorId = resolveInboundModel(body.model, cc);
869
926
  // Combo and policy selectors are never eligible, and routing them would advance round-robin
870
927
  // state or run the policy evaluator for a request that sends nothing.
@@ -875,11 +932,13 @@ export function nativeMessagesCountBody(
875
932
  route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic",
876
933
  );
877
934
  route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy);
935
+ const instance = configuredAnthropicInstance(config, route.providerName);
878
936
  const selector: NativeMessagesSelector = {
879
937
  ...rows,
880
938
  routeSelector: selectorId,
881
939
  claudeCode: cc,
882
- ...(route.provider.authMode === "oauth" ? { oauthFailoverQuorum: hasAnthropicFailoverQuorum() } : {}),
940
+ ...(route.provider.authMode === "oauth" && instance
941
+ ? { oauthFailoverQuorum: anthropicRoutingFor(instance).hasAnthropicFailoverQuorum() } : {}),
883
942
  };
884
943
  if (nativeMessagesDeclineReason(route, body, config, selector) !== undefined) return undefined;
885
944
  // The body without a credential: counting never resolves or refreshes an OAuth account.
@@ -63,6 +63,7 @@ import {
63
63
  type UsageStatus,
64
64
  } from "../usage/log";
65
65
  import type { RequestExecutionBudget } from "../lib/request-execution-budget";
66
+ import type { WorkflowSpendDenialDetail } from "../lib/workflow-budget";
66
67
  import { attributeFinalRequest, attributeSealedAttempt } from "./request-log-failure-attribution";
67
68
  import { debugAttemptDeliverySummary } from "../lib/debug";
68
69
  import {
@@ -203,6 +204,10 @@ export interface RequestLogContext {
203
204
  spendOutputCeilingTokens?: number;
204
205
  /** Pre-send input estimate reserved for spend only; unlike usageLogInputTokens it never enters usage. */
205
206
  spendInputEstimateTokens?: number;
207
+ /** Canonical provider-pool spend identity; independent of mutable, account-specific log labels. */
208
+ spendPoolId?: string;
209
+ /** Internal safe refusal detail for a request whose ceiling includes unidentified old pool spend. */
210
+ spendRefusalDetail?: WorkflowSpendDenialDetail;
206
211
  /** Settles this request's durable spend entries from `addFinalRequestLog`. */
207
212
  spendTracker?: RequestSpendSettlement;
208
213
  attempts?: PersistedUsageAttempt[];