@bitkyc08/opencodex 2.82.0 → 2.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (266) hide show
  1. package/gui/dist/assets/App-CZtqiGcw.js +56 -0
  2. package/gui/dist/assets/Tray-C9TGtwad.js +1 -0
  3. package/gui/dist/assets/{index-BAbcj51J.css → index-BB6uK6tU.css} +1 -1
  4. package/gui/dist/assets/index-CTm4l-HQ.js +94 -0
  5. package/gui/dist/assets/{quota-summary-DTBC8n7-.js → quota-summary-BbEeRfPY.js} +1 -1
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +2 -2
  8. package/src/adapters/coding-agent/protocol.ts +15 -8
  9. package/src/adapters/cursor/catalog.ts +2 -2
  10. package/src/adapters/cursor/effort-map.ts +1 -1
  11. package/src/adapters/devin/live-models.ts +2 -2
  12. package/src/adapters/google-truncation.ts +22 -0
  13. package/src/adapters/google.ts +7 -3
  14. package/src/adapters/ollama-native.ts +47 -15
  15. package/src/adapters/openai-chat/messages.ts +8 -4
  16. package/src/adapters/openai-responses/empty-assistant.ts +32 -0
  17. package/src/adapters/openai-responses/passthrough.ts +5 -0
  18. package/src/adapters/openai-responses/plaintext-reasoning-notice.ts +85 -0
  19. package/src/adapters/openai-responses/reasoning.ts +11 -1
  20. package/src/adapters/openai-responses/request-strips.ts +10 -2
  21. package/src/adapters/qoder/adapter.ts +16 -14
  22. package/src/bridge/errors.ts +26 -1
  23. package/src/bridge.ts +1 -1
  24. package/src/claude/context-windows.ts +57 -4
  25. package/src/claude/desktop-picker.ts +1 -1
  26. package/src/claude/intercept/connect-proxy.ts +10 -3
  27. package/src/claude/intercept/picker-ca-persistence.ts +3 -1
  28. package/src/claude/intercept/picker-listener.ts +49 -1
  29. package/src/claude/intercept/settings-no-proxy.ts +146 -0
  30. package/src/claude/intercept/settings.ts +101 -9
  31. package/src/cli/account-api.ts +65 -0
  32. package/src/cli/account-auth.ts +44 -12
  33. package/src/cli/account.ts +41 -5
  34. package/src/cli/capabilities-access-remote.ts +8 -0
  35. package/src/cli/capabilities-base.ts +13 -1
  36. package/src/cli/catalog-command-result.ts +2 -1
  37. package/src/cli/claude.ts +15 -6
  38. package/src/cli/config-command.ts +8 -2
  39. package/src/cli/debug.ts +8 -0
  40. package/src/cli/dispatch.ts +16 -3
  41. package/src/cli/export-command.ts +14 -1
  42. package/src/cli/hub-disable.ts +99 -0
  43. package/src/cli/hub.ts +4 -2
  44. package/src/cli/index.ts +13 -4
  45. package/src/cli/link-child.ts +104 -0
  46. package/src/cli/link-enrollment.ts +96 -0
  47. package/src/cli/link.ts +39 -24
  48. package/src/cli/observe-stream.ts +6 -1
  49. package/src/cli/observe.ts +10 -2
  50. package/src/cli/provider.ts +12 -0
  51. package/src/cli/registry.ts +5 -4
  52. package/src/cli/resolve.ts +1 -1
  53. package/src/cli/runtime-api.ts +27 -2
  54. package/src/cli/status.ts +104 -20
  55. package/src/client/connect.ts +39 -4
  56. package/src/client/hub-client.ts +14 -7
  57. package/src/client/hub-state.ts +25 -14
  58. package/src/client/link-home.ts +248 -0
  59. package/src/client/link-management-relay.ts +61 -0
  60. package/src/client/link-relay.ts +6 -1
  61. package/src/client/link-status.ts +14 -0
  62. package/src/client/machine-api.ts +14 -7
  63. package/src/client/machine-listener.ts +29 -4
  64. package/src/clients/config-export/model-metadata.ts +16 -0
  65. package/src/clients/config-export/zcode-store.ts +46 -11
  66. package/src/clients/config-export/zcode.ts +3 -5
  67. package/src/clients/config-export.ts +47 -17
  68. package/src/clients/omo-role-models.ts +6 -3
  69. package/src/codex/agent-role-models.ts +26 -8
  70. package/src/codex/auth-api/account-list.ts +17 -2
  71. package/src/codex/auth-api/account-selection.ts +17 -7
  72. package/src/codex/auth-api/pool-mode-gate.ts +4 -5
  73. package/src/codex/auth-api/routes.ts +5 -1
  74. package/src/codex/auth-context.ts +8 -3
  75. package/src/codex/autostart-health.ts +3 -1
  76. package/src/codex/catalog/bundled.ts +5 -1
  77. package/src/codex/catalog/effort.ts +2 -2
  78. package/src/codex/catalog/metadata.ts +4 -2
  79. package/src/codex/catalog/native-models.ts +17 -3
  80. package/src/codex/catalog/provider-models.ts +25 -0
  81. package/src/codex/desktop-app/darwin.ts +1 -1
  82. package/src/codex/desktop-app/handoff.ts +1 -1
  83. package/src/codex/desktop-app/linux.ts +1 -1
  84. package/src/codex/desktop-app/lock.ts +1 -1
  85. package/src/codex/desktop-app/types.ts +1 -1
  86. package/src/codex/desktop-app/windows.ts +1 -1
  87. package/src/codex/desktop-app-restart.ts +1 -1
  88. package/src/codex/main-account.ts +6 -7
  89. package/src/codex/native-profile-startup.ts +4 -1
  90. package/src/codex/prompt-journal.ts +20 -2
  91. package/src/codex/prompt-layers/encoding.ts +49 -0
  92. package/src/codex/prompt-layers/import-source.ts +30 -0
  93. package/src/codex/prompt-layers/toml-edit.ts +269 -37
  94. package/src/codex/prompt-layers/toml-read.ts +52 -4
  95. package/src/codex/prompt-layers.ts +383 -20
  96. package/src/codex/prompt-lock-claim.ts +13 -3
  97. package/src/codex/prompt-lock-evidence.ts +3 -3
  98. package/src/codex/prompt-lock-owner.ts +14 -6
  99. package/src/codex/prompt-text-probe.ts +34 -5
  100. package/src/codex/quota.ts +13 -6
  101. package/src/codex/role-auto-assign.ts +2 -5
  102. package/src/codex/routing/active-account.ts +21 -0
  103. package/src/codex/routing.ts +1 -1
  104. package/src/codex/runtime.ts +196 -8
  105. package/src/codex/shim-overlay.ts +8 -4
  106. package/src/codex/shim.ts +1 -1
  107. package/src/combos/failover.ts +3 -0
  108. package/src/config/api-key-reconcile.ts +34 -0
  109. package/src/config/diagnostics.ts +16 -0
  110. package/src/config/feature-flags.ts +5 -0
  111. package/src/config/live-reconcile.ts +43 -29
  112. package/src/config/live-topology.ts +29 -0
  113. package/src/config/load-degrade.ts +2 -1
  114. package/src/config/paths.ts +3 -0
  115. package/src/config/salvage.ts +3 -1
  116. package/src/config/schema/config-schema.ts +2 -0
  117. package/src/generated/compatibility-version.json +373 -217
  118. package/src/grok/grpc-web.ts +23 -1
  119. package/src/grok/inject.ts +245 -221
  120. package/src/grok/reset-coupon-ledger.ts +293 -57
  121. package/src/grok/reset-coupons.ts +15 -6
  122. package/src/integrations/cursor-detect.ts +1 -1
  123. package/src/integrations/mutation-plan.ts +13 -7
  124. package/src/integrations/registry.ts +45 -11
  125. package/src/integrations/state.ts +19 -7
  126. package/src/integrations/target.ts +42 -5
  127. package/src/integrations/writer.ts +14 -6
  128. package/src/lib/app-owned-memory-stores.ts +4 -0
  129. package/src/lib/app-owned-memory.ts +11 -0
  130. package/src/lib/errors.ts +25 -0
  131. package/src/lib/json-byte-size.ts +31 -0
  132. package/src/lib/optional-shutdown-hooks.ts +35 -0
  133. package/src/lib/outbound-credential-registry.ts +210 -0
  134. package/src/lib/passive-read.ts +35 -0
  135. package/src/lib/redact.ts +2 -2
  136. package/src/lib/shadow-call.ts +44 -5
  137. package/src/link/codex-settings.ts +155 -0
  138. package/src/link/management-routes.ts +89 -0
  139. package/src/link/remote-probe.ts +38 -0
  140. package/src/link/ssh-argv.ts +26 -5
  141. package/src/link/ssh-runner.ts +58 -27
  142. package/src/link/status-projection.ts +22 -4
  143. package/src/link/supervisor.ts +144 -49
  144. package/src/link/tunnel-state.ts +11 -4
  145. package/src/oauth/anthropic-continuity.ts +4 -3
  146. package/src/oauth/anthropic-identity.ts +13 -2
  147. package/src/oauth/anthropic-model-quota.ts +13 -1
  148. package/src/oauth/anthropic-routing.ts +230 -23
  149. package/src/oauth/anthropic.ts +18 -4
  150. package/src/oauth/google-antigravity.ts +24 -7
  151. package/src/oauth/index.ts +25 -16
  152. package/src/oauth/kiro-account-identity.ts +106 -0
  153. package/src/oauth/kiro.ts +48 -5
  154. package/src/oauth/meta-muse-device.ts +1 -1
  155. package/src/oauth/plan.ts +29 -0
  156. package/src/oauth/store.ts +40 -3
  157. package/src/oauth/types.ts +3 -1
  158. package/src/plugins/loader.ts +11 -0
  159. package/src/providers/anthropic-billing-ownership.ts +74 -0
  160. package/src/providers/anthropic-billing-recovery.ts +31 -0
  161. package/src/providers/anthropic-billing-refusal.ts +55 -0
  162. package/src/providers/anthropic-fast.ts +1 -1
  163. package/src/providers/anthropic-reset-admission.ts +62 -0
  164. package/src/providers/anthropic-reset-grant-ledger.ts +29 -7
  165. package/src/providers/api-key-resolve.ts +17 -2
  166. package/src/providers/devin-cli-authmode-migration.ts +1 -1
  167. package/src/providers/devin-provider-merge-migration.ts +1 -1
  168. package/src/providers/github-copilot-auto.ts +169 -0
  169. package/src/providers/key-failover.ts +52 -5
  170. package/src/providers/model-presets.ts +1 -1
  171. package/src/providers/model-rename-startup.ts +5 -3
  172. package/src/providers/muse-subscription-usage.ts +1 -1
  173. package/src/providers/quota/account-cache.ts +11 -0
  174. package/src/providers/quota/anthropic-account-quota.ts +4 -2
  175. package/src/providers/quota/report-cache.ts +14 -0
  176. package/src/providers/quota/vendor-probes-oauth.ts +3 -3
  177. package/src/providers/quota.ts +4 -1
  178. package/src/providers/registry/entries-core.ts +20 -20
  179. package/src/providers/registry/entries-extended.ts +4 -1
  180. package/src/providers/registry/model-seeds.ts +20 -3
  181. package/src/providers/request-pacing.ts +9 -0
  182. package/src/providers/stale-no-vision-seed-migration.ts +72 -0
  183. package/src/providers/xai-fast-model.ts +1 -1
  184. package/src/responses/compaction.ts +14 -0
  185. package/src/responses/parser.ts +7 -1
  186. package/src/server/auth-cors.ts +42 -0
  187. package/src/server/configured-credentials.ts +194 -0
  188. package/src/server/inbound-body-admission.ts +24 -7
  189. package/src/server/index/link-listener.ts +30 -17
  190. package/src/server/index/link-management.ts +58 -0
  191. package/src/server/index/optional-listeners.ts +5 -1
  192. package/src/server/index/serve-options.ts +21 -2
  193. package/src/server/index.ts +1 -1
  194. package/src/server/lifecycle.ts +33 -5
  195. package/src/server/management/agent-settings-routes.ts +28 -10
  196. package/src/server/management/anthropic-reset-grant-routes.ts +17 -1
  197. package/src/server/management/codex-agent-role-routes.ts +19 -6
  198. package/src/server/management/codex-prompt-routes.ts +136 -5
  199. package/src/server/management/codex-role-auto-assign.ts +1 -1
  200. package/src/server/management/grok-coupon-routes.ts +333 -57
  201. package/src/server/management/link-enrollment.ts +192 -0
  202. package/src/server/management/link-route-state.ts +185 -0
  203. package/src/server/management/link-routes.ts +92 -390
  204. package/src/server/management/model-rows.ts +3 -1
  205. package/src/server/management/provider-overwrite-carry.ts +10 -0
  206. package/src/server/management/provider-routes.ts +1 -0
  207. package/src/server/management/route-registry.ts +7 -5
  208. package/src/server/management/shared.ts +3 -0
  209. package/src/server/management-api.ts +3 -1
  210. package/src/server/messages-native.ts +68 -42
  211. package/src/server/relay-eager.ts +4 -0
  212. package/src/server/relay.ts +18 -7
  213. package/src/server/request-log-failure-attribution.ts +3 -0
  214. package/src/server/request-log-filter.ts +6 -1
  215. package/src/server/request-log-terminal-status.ts +243 -0
  216. package/src/server/request-log.ts +15 -118
  217. package/src/server/responses/adapter-continuation.ts +9 -1
  218. package/src/server/responses/adapter-dispatch.ts +293 -81
  219. package/src/server/responses/code-mode-collab-surface.ts +205 -0
  220. package/src/server/responses/codex-ws-exchange.ts +147 -6
  221. package/src/server/responses/codex-ws-wire.ts +203 -0
  222. package/src/server/responses/collaboration.ts +36 -7
  223. package/src/server/responses/compact.ts +129 -50
  224. package/src/server/responses/context-overflow.ts +13 -1
  225. package/src/server/responses/core-codex-account.ts +105 -1
  226. package/src/server/responses/core-combo-failure.ts +14 -0
  227. package/src/server/responses/core-combo.ts +7 -2
  228. package/src/server/responses/core-errors.ts +29 -1
  229. package/src/server/responses/core-opaque-recovery.ts +5 -5
  230. package/src/server/responses/core-options.ts +2 -0
  231. package/src/server/responses/core.ts +6 -6
  232. package/src/server/responses/deferred-collab-surface.ts +158 -0
  233. package/src/server/responses/deferred-send-reporter.ts +25 -0
  234. package/src/server/responses/empty-completion-guard.ts +18 -2
  235. package/src/server/responses/fetch-helpers.ts +12 -1
  236. package/src/server/responses/jev-model-invoke.ts +1 -1
  237. package/src/server/responses/passthrough-delivery.ts +4 -1
  238. package/src/server/responses/passthrough-dispatch.ts +130 -64
  239. package/src/server/responses/passthrough-execution.ts +3 -3
  240. package/src/server/responses/request-prepare.ts +6 -4
  241. package/src/server/responses/request-transport.ts +131 -13
  242. package/src/server/responses/run-turn-execution.ts +13 -5
  243. package/src/server/responses/sidecar-execution.ts +16 -2
  244. package/src/server/responses/spend-storage-error.ts +33 -0
  245. package/src/server/responses/ws-upstream.ts +9 -1
  246. package/src/server/startup-action-control.ts +22 -2
  247. package/src/server/startup-health-cache.ts +4 -0
  248. package/src/server/system-env-shell.ts +7 -2
  249. package/src/server/system-env.ts +11 -1
  250. package/src/service/cli.ts +5 -1
  251. package/src/service/desktop-startup.ts +147 -34
  252. package/src/service/desktop-supervision.d.mts +27 -1
  253. package/src/service/desktop-supervision.mjs +111 -5
  254. package/src/service/health-sentinel-launch.ts +176 -0
  255. package/src/service/health-sentinel-main.ts +447 -0
  256. package/src/service/health-sentinel-policy.ts +241 -0
  257. package/src/service/health-sentinel-probe.ts +54 -0
  258. package/src/service/health-sentinel-windows.ts +87 -0
  259. package/src/service/linux-linger.ts +20 -0
  260. package/src/types/config.ts +5 -0
  261. package/src/types/request.ts +5 -0
  262. package/src/usage/expected-prices.ts +5 -1
  263. package/src/usage/log.ts +19 -1
  264. package/gui/dist/assets/App-Ds_qL7o8.js +0 -54
  265. package/gui/dist/assets/Tray-BqHF5Trd.js +0 -1
  266. package/gui/dist/assets/index-D3FBCNiP.js +0 -94
@@ -5,6 +5,7 @@ import { getDefaultConfig, initializePersistedConfigIfMissing, loadConfig, mutat
5
5
  import { resolveProviderApiKey } from "../providers/key-store";
6
6
  import { projectEmail } from "../lib/privacy";
7
7
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
8
+ import { KiroUserIdentityUnavailableError, kiroReauthIdentityMatches } from "./kiro-account-identity";
8
9
  import {
9
10
  OAuthMutationBusyError,
10
11
  OAuthRefreshIntentIOError,
@@ -86,6 +87,7 @@ import { codexAccountNamespaceProviderCollisionError } from "../codex/account-na
86
87
 
87
88
  import { REFRESH_SKEW_MS } from "./refresh-policy";
88
89
  import { newerClaudeCredentialForInstance, captureAnthropicCredentialOwnerForInstance } from "./anthropic-continuity";
90
+ import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
89
91
  import { mergeAnthropicIdentity, type AnthropicIdentityResolver } from "./anthropic-identity";
90
92
  import { AnthropicCrossInstanceDuplicateError, AnthropicLocalCliImportError, assertAnthropicCredentialSource } from "./store-anthropic-instance";
91
93
  export interface OAuthAccessSnapshot {
@@ -360,7 +362,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
360
362
  * Removed provider ids that still name a live successor.
361
363
  *
362
364
  * `devin-cli` was merged into `devin` (import-first login absorbed the CLI
363
- * credential import; devlog/_plan/260913_devin_provider_merge). The id can
365
+ * credential import; devlog/_fin/260913_devin_provider_merge). The id can
364
366
  * still arrive here from a saved config row or a stored credential slot that
365
367
  * the startup migration has not rekeyed yet, and from a user typing the old
366
368
  * name at `ocx login`. It is deliberately NOT an OAUTH_PROVIDERS entry:
@@ -491,6 +493,9 @@ export function publicOAuthAuthenticationErrorMessage(error: unknown): string {
491
493
  || error instanceof AnthropicInstanceCollisionError
492
494
  || error instanceof AnthropicCrossInstanceDuplicateError
493
495
  || error instanceof AnthropicLocalCliImportError
496
+ // Fixed, account-free text: a Kiro login that could not name its user left the existing
497
+ // organization account in place, and the user must confirm the signed-in CLI user (#6641).
498
+ || error instanceof KiroUserIdentityUnavailableError
494
499
  ) return error.message;
495
500
  return "OAuth authentication failed. Check the OpenCodex account status and retry.";
496
501
  }
@@ -749,6 +754,7 @@ function merged(fresh: OAuthCredentials, previous: OAuthCredentials): OAuthCrede
749
754
  return {
750
755
  ...fresh,
751
756
  ...mergeAnthropicIdentity(fresh, previous),
757
+ plan: mergeOAuthPlan(fresh, previous),
752
758
  // Shared: a refresh function returns "local-cli" only when the credential it hands back
753
759
  // still is the local CLI's (Devin re-reading the CLI file, Meta Muse echoing its durable
754
760
  // CLI key). Relabelling that "oauth" would stop the next forced refresh from re-reading it.
@@ -1617,15 +1623,22 @@ export async function runLogin(
1617
1623
  if (!existing.accountId && !existing.email) {
1618
1624
  throw new OAuthReauthIdentityUnverifiedError();
1619
1625
  }
1620
- const identityMatches = existing.accountId && cred.accountId
1621
- ? existing.accountId === cred.accountId
1622
- : existing.email && cred.email
1623
- ? existing.email.toLowerCase() === cred.email.toLowerCase()
1624
- : false;
1626
+ // A Kiro organization profile ARN is shared by its users, so it alone is not the account.
1627
+ const identityMatches = provider === "kiro"
1628
+ ? kiroReauthIdentityMatches(existing, cred, getAccountSet("kiro")?.accounts
1629
+ .filter(account => account.id !== opts.reauthAccountId).map(account => account.credential))
1630
+ : existing.accountId && cred.accountId
1631
+ ? existing.accountId === cred.accountId
1632
+ : existing.email && cred.email
1633
+ ? existing.email.toLowerCase() === cred.email.toLowerCase()
1634
+ : false;
1625
1635
  if (!identityMatches) {
1626
1636
  throw new OAuthReauthIdentityMismatchError();
1627
1637
  }
1628
- await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, {
1638
+ // Proven same account: a reauth whose tier read failed keeps the stored label.
1639
+ const reauthPlan = mergeOAuthPlan(cred, existing);
1640
+ const reauthCred = reauthPlan === undefined ? cred : { ...cred, plan: reauthPlan };
1641
+ await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, reauthCred, {
1629
1642
  assertBeforePersist: deps.assertCurrentOwner,
1630
1643
  rotateLoginId: true,
1631
1644
  });
@@ -1736,10 +1749,9 @@ export interface OAuthAccountSummary {
1736
1749
  * version looked and upstream did not say. Omitting it would make those indistinguishable and
1737
1750
  * invite a consumer to assume a tier.
1738
1751
  *
1739
- * Every OAuth provider reports `null` today. Anthropic's `/api/oauth/usage` returns quota
1740
- * buckets only — `five_hour`, `seven_day`, the model-scoped weekly windows and `limits[]` —
1741
- * and carries no subscription/tier field, and its token response carries none either. See
1742
- * `fetchAnthropicUsageQuota` in `src/providers/quota.ts`.
1752
+ * Reports the validated upstream tier stored with the credential. Anthropic profile reads
1753
+ * supply the organization tier; Antigravity loadCodeAssist reads prefer paidTier to currentTier.
1754
+ * Missing or invalid labels remain null; quota percentages never establish a subscription.
1743
1755
  */
1744
1756
  plan: string | null;
1745
1757
  }
@@ -1764,11 +1776,8 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
1764
1776
  ...(accountNeedsReauthForStatus(provider, a) ? { needsReauth: true } : {}),
1765
1777
  ...(a.needsReauth && a.needsReauthReason === "verify_account" ? { needsReauthReason: a.needsReauthReason } : {}),
1766
1778
  expiresAt: a.credential.expires,
1767
- // Explicitly null rather than omitted — see OAuthAccountSummary.plan. No OAuth provider
1768
- // exposes a subscription tier today, so there is nothing truthful to put here; deriving one
1769
- // from quota percentages is not possible, because they are normalized per account and a
1770
- // half-consumed small seat is indistinguishable from a half-consumed large one.
1771
- plan: null,
1779
+ // Explicitly null rather than omitted when upstream tier metadata is unknown.
1780
+ plan: normalizeOAuthPlan(a.credential.plan) ?? null,
1772
1781
  }));
1773
1782
 
1774
1783
  // A stored credential counts as "logged in" when it exists and is not marked for
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Kiro account identity (#6641).
3
+ *
4
+ * A Kiro profile ARN names an organization (IAM Identity Center) profile, and every user of
5
+ * that profile shares it, so it cannot tell two users apart. The user-level identifier OCX can
6
+ * observe is the email that same-session `kiro-cli whoami --format json` reports. Account
7
+ * identity is therefore the ARN together with that email; `accountId` keeps the ARN because
8
+ * request routing, rotated-session recovery and persisted evidence read it as the profile.
9
+ *
10
+ * Stored rows are never rewritten to a new key. A legacy row keeps matching the shape it was
11
+ * saved with, and a row that predates the email is only claimed by a login proven to be the
12
+ * same CLI session (identical refresh token). A login that cannot name its user keeps the legacy
13
+ * profile identity, but is refused rather than overwrite an identified user of that profile.
14
+ */
15
+ import type { OAuthCredentials, ProviderAccount } from "./types";
16
+
17
+ type KiroIdentityFields = Pick<OAuthCredentials, "accountId" | "email">;
18
+
19
+ export class KiroUserIdentityUnavailableError extends Error {
20
+ constructor() {
21
+ super(
22
+ "Kiro CLI did not report which user is signed in, so OpenCodex cannot tell this login apart "
23
+ + "from the identified users of the same Kiro profile. No existing account was replaced. "
24
+ + "Confirm that `kiro-cli whoami` reports the signed-in user, then retry; to renew one "
25
+ + "account, use its Reauthenticate action.",
26
+ );
27
+ this.name = "KiroUserIdentityUnavailableError";
28
+ }
29
+ }
30
+
31
+ export function kiroUserEmail(credential: Pick<OAuthCredentials, "email">): string | undefined {
32
+ const email = credential.email?.trim().toLowerCase();
33
+ return email ? email : undefined;
34
+ }
35
+
36
+ /** ARN + user email when both are known; otherwise whichever one the credential carries. */
37
+ export function kiroIdentityKey(credential: KiroIdentityFields): string | undefined {
38
+ const email = kiroUserEmail(credential);
39
+ if (credential.accountId && email) return `${credential.accountId}\n${email}`;
40
+ return credential.accountId ?? email;
41
+ }
42
+
43
+ function sameCliSession(account: ProviderAccount, incoming: OAuthCredentials): boolean {
44
+ return Boolean(incoming.refresh) && account.credential.refresh === incoming.refresh;
45
+ }
46
+
47
+ /**
48
+ * Pick the stored row an identity-bearing Kiro login updates, or undefined to append a new row.
49
+ * Throws KiroUserIdentityUnavailableError instead of guessing between identified users.
50
+ */
51
+ export function findKiroAccountForLogin(
52
+ accounts: readonly ProviderAccount[],
53
+ incoming: OAuthCredentials,
54
+ ): ProviderAccount | undefined {
55
+ const key = kiroIdentityKey(incoming);
56
+ if (!key) return undefined;
57
+ const profileArn = incoming.accountId;
58
+ const sameProfile = profileArn
59
+ ? accounts.filter(account => account.credential.accountId === profileArn)
60
+ : [];
61
+ if (profileArn && !kiroUserEmail(incoming)) {
62
+ // whoami named no user. Without any identified user the profile ARN stays the identity, as it
63
+ // always was. Once the profile has an identified user, a profile-only match could be anyone
64
+ // in the organization: only the same CLI session may update a row; otherwise refuse.
65
+ if (sameProfile.some(account => kiroUserEmail(account.credential))) {
66
+ const proven = sameProfile.find(account => sameCliSession(account, incoming));
67
+ if (proven) return proven;
68
+ throw new KiroUserIdentityUnavailableError();
69
+ }
70
+ }
71
+ const exact = accounts.find(account => kiroIdentityKey(account.credential) === key);
72
+ if (exact) return exact;
73
+ if (profileArn && kiroUserEmail(incoming)) {
74
+ // A row saved before whoami named its user: only the same CLI session proves it is this user.
75
+ return sameProfile.find(account => !kiroUserEmail(account.credential) && sameCliSession(account, incoming));
76
+ }
77
+ return undefined;
78
+ }
79
+
80
+ /** A proven same-session update without whoami keeps the user email the row already has. */
81
+ export function withRetainedKiroUser(incoming: OAuthCredentials, existing: OAuthCredentials): OAuthCredentials {
82
+ if (incoming.email || !existing.email || incoming.accountId !== existing.accountId) return incoming;
83
+ return { ...incoming, email: existing.email };
84
+ }
85
+
86
+ /**
87
+ * Reauth may refresh a slot only with the same profile AND, once known, the same user. A slot
88
+ * saved before its user was known is renewable only by this explicit reauth, unless the incoming
89
+ * user already owns another slot of the profile (then it is provably someone else).
90
+ */
91
+ export function kiroReauthIdentityMatches(
92
+ existing: KiroIdentityFields,
93
+ incoming: KiroIdentityFields,
94
+ otherAccounts: readonly KiroIdentityFields[] = [],
95
+ ): boolean {
96
+ const existingEmail = kiroUserEmail(existing);
97
+ const incomingEmail = kiroUserEmail(incoming);
98
+ if (existing.accountId && incoming.accountId) {
99
+ if (existing.accountId !== incoming.accountId) return false;
100
+ if (existingEmail !== undefined) return existingEmail === incomingEmail;
101
+ return incomingEmail === undefined || !otherAccounts.some(other =>
102
+ other.accountId === incoming.accountId && kiroUserEmail(other) === incomingEmail);
103
+ }
104
+ return Boolean(existingEmail && incomingEmail && existingEmail === incomingEmail);
105
+ }
106
+
package/src/oauth/kiro.ts CHANGED
@@ -27,8 +27,9 @@ import {
27
27
  type KiroImportDiagnostic,
28
28
  } from "./kiro-credentials";
29
29
  import { homedir } from "node:os";
30
- import { getAccountSet, saveAccountCredential } from "./store";
30
+ import { bindKiroAccountUserEmail, getAccountSet, saveAccountCredential } from "./store";
31
31
  import { KIRO_BUILDER_ID_SERVICE_PROFILE_ARN } from "../adapters/kiro-constants";
32
+ import { kiroUserEmail } from "./kiro-account-identity";
32
33
 
33
34
  const DEFAULT_REGION = "us-east-1";
34
35
  const REFRESH_URL = "https://prod.{region}.auth.desktop.kiro.dev/refreshToken";
@@ -246,8 +247,10 @@ export function environmentKiroRoutingMetadata(): Pick<KiroOAuthMetadata, "profi
246
247
  }
247
248
 
248
249
  /**
249
- * Before Add-account switches the external CLI identity, bind any matching legacy identity-less
250
- * OCX row to the current CLI session. Unmatched identity-less rows are left alone here so a
250
+ * Before Add-account switches the external CLI identity, bind any matching legacy OCX row to the
251
+ * current CLI session: identity-less rows gain their identity, and rows keyed only by a profile
252
+ * ARN gain the same-session whoami user, so the user being added next is told apart from them
253
+ * when both share an organization profile (#6641). Unmatched rows are left alone here so a
251
254
  * cancelled browser login cannot destroy the only stored credential; after a successful add they
252
255
  * remain selectable but cannot borrow the new CLI identity (and reauth refuses identity-less slots).
253
256
  */
@@ -258,7 +261,7 @@ async function bindMatchingLegacyIdentitylessKiroAccounts(
258
261
  const set = getAccountSet("kiro");
259
262
  if (!set) return;
260
263
  const legacyAccounts = set.accounts.filter(
261
- account => account.credential.accountId === undefined && account.credential.email === undefined,
264
+ account => account.credential.email === undefined,
262
265
  );
263
266
  if (legacyAccounts.length === 0) return;
264
267
 
@@ -275,7 +278,14 @@ async function bindMatchingLegacyIdentitylessKiroAccounts(
275
278
  if (!refresh || imported.refresh !== refresh) continue;
276
279
  const bound = await oauthCredentialFromImported(imported, runner, signal);
277
280
  if (!bound.accountId && !bound.email) continue;
278
- await saveAccountCredential("kiro", account.id, bound);
281
+ const storedArn = account.credential.accountId;
282
+ if (storedArn === undefined) {
283
+ await saveAccountCredential("kiro", account.id, bound);
284
+ continue;
285
+ }
286
+ // An ARN-keyed row only gains its user; tokens, routing, plan and health stay as stored.
287
+ if (bound.accountId !== storedArn || !bound.email) continue;
288
+ await bindKiroAccountUserEmail(account.id, { profileArn: storedArn, refresh, email: bound.email });
279
289
  }
280
290
  }
281
291
 
@@ -659,10 +669,40 @@ function metadataForRotatedKiroCliCredential(
659
669
  };
660
670
  }
661
671
 
672
+ /**
673
+ * Users of one organization share its profile ARN (#6641). A row that names its user adopts the
674
+ * local CLI session only when same-session whoami reports that user and the CLI still holds the
675
+ * matched session. A row without a user keeps the profile match only while no other stored Kiro
676
+ * row shares the profile. Cancellation propagates as cancellation, never as the refresh refusal.
677
+ */
678
+ async function localKiroCliSessionBelongsToUser(
679
+ credential: OAuthCredentials,
680
+ local: ImportedKiroCredential,
681
+ runner: KiroCliRunner,
682
+ signal?: AbortSignal,
683
+ ): Promise<boolean> {
684
+ const email = kiroUserEmail(credential);
685
+ if (!email) {
686
+ return !(getAccountSet("kiro")?.accounts ?? []).some(account =>
687
+ account.credential.refresh !== credential.refresh && account.credential.accountId === local.profileArn);
688
+ }
689
+ const identity = await readKiroCliIdentity(runner, signal);
690
+ signal?.throwIfAborted();
691
+ if (identity.email !== email) return false;
692
+ let current: ImportedKiroCredential | null;
693
+ try {
694
+ current = readKiroCliSqliteCredential();
695
+ } catch {
696
+ return false;
697
+ }
698
+ return current !== null && (current.refresh || current.access) === (local.refresh || local.access);
699
+ }
700
+
662
701
  export async function refreshKiroToken(
663
702
  refresh: string,
664
703
  signal?: AbortSignal,
665
704
  credential?: OAuthCredentials,
705
+ options: { cliRunner?: KiroCliRunner } = {},
666
706
  ): Promise<OAuthCredentials> {
667
707
  if (!refresh) throw new Error("Kiro: no refresh token available (re-run `kiro-cli login`).");
668
708
  try {
@@ -672,6 +712,9 @@ export async function refreshKiroToken(
672
712
  if (!credential) throw error;
673
713
  const local = matchingRotatedKiroCliCredential(refresh, credential);
674
714
  if (!local) throw error;
715
+ if (!await localKiroCliSessionBelongsToUser(credential, local, options.cliRunner ?? defaultKiroCliRunner, signal)) {
716
+ throw error;
717
+ }
675
718
  const retryMetadata = metadataForRotatedKiroCliCredential(credential, local);
676
719
  try {
677
720
  const fresh = await refreshAwsSsoOidcToken(local.refresh, signal, {
@@ -6,7 +6,7 @@
6
6
  * resulting account token into the `LLM|` Model API key our request path sends as a
7
7
  * bearer.
8
8
  *
9
- * Protocol source: devlog/_plan/260912_muse_device_oauth/001_reference_measurements.md.
9
+ * Protocol source: devlog/_fin/260912_muse_device_oauth/001_reference_measurements.md.
10
10
  * It is second-party, not vendor documentation, so every response is parsed defensively
11
11
  * and every failure names a kind instead of throwing a bare string.
12
12
  *
@@ -0,0 +1,29 @@
1
+ import type { OAuthCredentials } from "./types";
2
+
3
+ /** Display-only upstream tier; never infer a plan from normalized quota percentages. */
4
+ export function normalizeOAuthPlan(value: unknown): string | undefined {
5
+ if (typeof value !== "string") return undefined;
6
+ const plan = value.trim();
7
+ return plan.length <= 64 && /^[a-z0-9_.-]+$/.test(plan) ? plan : undefined;
8
+ }
9
+
10
+ /**
11
+ * Same account by the strongest identifier both credentials carry, compared kind for kind
12
+ * (Anthropic UUID, then accountId, then case-insensitive email); undefined when none overlap.
13
+ */
14
+ function sameAccount(a: OAuthCredentials, b: OAuthCredentials): boolean | undefined {
15
+ const pairs: Array<[string | undefined, string | undefined]> = [
16
+ [a.anthropicIdentity?.accountUuid, b.anthropicIdentity?.accountUuid],
17
+ [a.accountId, b.accountId],
18
+ [a.email?.toLowerCase(), b.email?.toLowerCase()],
19
+ ];
20
+ for (const [left, right] of pairs) if (left !== undefined && right !== undefined) return left === right;
21
+ return undefined;
22
+ }
23
+
24
+ /** An observed unknown clears old metadata; an account replacement cannot inherit a plan. */
25
+ export function mergeOAuthPlan(fresh: OAuthCredentials, previous: OAuthCredentials): string | null | undefined {
26
+ if (fresh.plan !== undefined) return normalizeOAuthPlan(fresh.plan) ?? null;
27
+ if (sameAccount(fresh, previous) === false) return undefined;
28
+ return previous.plan === null ? null : normalizeOAuthPlan(previous.plan);
29
+ }
@@ -19,7 +19,9 @@
19
19
  * distinct one. Kimi extracts JWT `user_id`/`sub` as accountId; Cursor extracts JWT `sub` —
20
20
  * both append distinct identified accounts under multiauth.
21
21
  */
22
+ import { normalizeOAuthPlan, mergeOAuthPlan } from "./plan";
22
23
  import { normalizeAnthropicIdentity, matchesAnthropicImport, mergeAnthropicIdentity } from "./anthropic-identity";
24
+ import { findKiroAccountForLogin, withRetainedKiroUser } from "./kiro-account-identity";
23
25
  import { isAnthropicInstanceId, type AnthropicInstanceId } from "../providers/anthropic-instance-id";
24
26
  import { assertNoCrossAnthropicRegistration, assertAnthropicCredentialSource, assertAnthropicInstanceLoginConfig } from "./store-anthropic-instance";
25
27
  import { createHash, randomUUID } from "node:crypto";
@@ -537,6 +539,7 @@ function normalizeCredential(cred: unknown): OAuthCredentials | null {
537
539
  if (typeof candidate.accountId === "string" && candidate.accountId.length > 0) normalized.accountId = candidate.accountId;
538
540
  const identity = normalizeAnthropicIdentity(candidate.anthropicIdentity, candidate.access);
539
541
  if (identity) normalized.anthropicIdentity = identity;
542
+ if (candidate.plan !== undefined) normalized.plan = normalizeOAuthPlan(candidate.plan) ?? null;
540
543
  if (isCredentialSource(candidate.source)) normalized.source = candidate.source;
541
544
  if (typeof candidate.projectId === "string" && candidate.projectId.length > 0) normalized.projectId = candidate.projectId;
542
545
  if (typeof candidate.apiBaseUrl === "string" && candidate.apiBaseUrl.length > 0) {
@@ -905,6 +908,7 @@ export async function saveCredentialWithReceipt(
905
908
  const existing = set.accounts.find(a => matchesAnthropicImport(a.credential, safe));
906
909
  if (existing) {
907
910
  existing.credential = { ...safe, ...mergeAnthropicIdentity(safe, existing.credential),
911
+ plan: mergeOAuthPlan(safe, existing.credential),
908
912
  accountId: safe.accountId ?? existing.credential.accountId, email: safe.email ?? existing.credential.email };
909
913
  set.selectionRevision = previousSelectionRevision;
910
914
  delete existing.needsReauth;
@@ -918,9 +922,17 @@ export async function saveCredentialWithReceipt(
918
922
  accountId = id;
919
923
  }
920
924
  } else if (identity) {
921
- const existing = set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
925
+ // Kiro: a shared organization profile ARN is not a user, so its identity also carries the
926
+ // same-session whoami email (#6641); the helper never guesses between identified users.
927
+ const existing = provider === "kiro"
928
+ ? findKiroAccountForLogin(set.accounts, safe)
929
+ : set.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity);
922
930
  if (existing) {
923
- existing.credential = safe;
931
+ const incoming = provider === "kiro" ? withRetainedKiroUser(safe, existing.credential) : safe;
932
+ // Same identity: a login whose tier read failed keeps the stored label (plan absent),
933
+ // while an observed unknown (plan null) still clears it.
934
+ const plan = mergeOAuthPlan(incoming, existing.credential);
935
+ existing.credential = plan === undefined ? incoming : { ...incoming, plan };
924
936
  delete existing.needsReauth;
925
937
  delete existing.needsReauthReason;
926
938
  delete existing.refreshAttentionGeneration;
@@ -932,7 +944,13 @@ export async function saveCredentialWithReceipt(
932
944
  // active identity-less row in place prevents a stale duplicate that stays selectable
933
945
  // and would re-refresh into a second row with the same identity.
934
946
  const active = set.accounts.find(a => a.id === set.activeAccountId);
935
- if (!opts.preserveIdentityless && active && active.credential.accountId === undefined && active.credential.email === undefined) {
947
+ // Kiro rows of one organization differ only by user: while another row shares the profile,
948
+ // an unmatched Kiro login claims an identity-less slot only as the same CLI session (#6641).
949
+ const legacyUpgrade = active !== undefined && active.credential.accountId === undefined
950
+ && active.credential.email === undefined
951
+ && (provider !== "kiro" || (Boolean(safe.refresh) && active.credential.refresh === safe.refresh)
952
+ || !set.accounts.some(a => safe.accountId !== undefined && a.credential.accountId === safe.accountId));
953
+ if (!opts.preserveIdentityless && active && legacyUpgrade) {
936
954
  active.credential = safe;
937
955
  delete active.needsReauth;
938
956
  delete active.needsReauthReason;
@@ -1014,6 +1032,25 @@ export async function saveCredential(
1014
1032
  await saveCredentialWithReceipt(provider, cred, opts);
1015
1033
  }
1016
1034
 
1035
+ /**
1036
+ * Record the same-session whoami user on a Kiro row saved before whoami named it (#6641).
1037
+ * Applies only while the row still holds the proven session's refresh token and profile ARN
1038
+ * and has no user yet; tokens, routing, plan and health are left exactly as stored.
1039
+ */
1040
+ export async function bindKiroAccountUserEmail(
1041
+ accountId: string,
1042
+ proof: { profileArn: string; refresh: string; email: string },
1043
+ ): Promise<boolean> {
1044
+ return await mutateStore(store => {
1045
+ const account = store.kiro?.accounts.find(candidate => candidate.id === accountId);
1046
+ const credential = account?.credential;
1047
+ if (!credential || credential.email !== undefined) return false;
1048
+ if (credential.accountId !== proof.profileArn || credential.refresh !== proof.refresh) return false;
1049
+ credential.email = proof.email;
1050
+ return true;
1051
+ }, [accountId, proof]);
1052
+ }
1053
+
1017
1054
  /** Native device approval always gets a new slot; no unverified identity can replace one. */
1018
1055
  export async function appendKiroAccountFromDeviceLogin(
1019
1056
  cred: OAuthCredentials,
@@ -30,7 +30,7 @@ export interface KiroOAuthMetadata {
30
30
  *
31
31
  * The Model API is authenticated by the `LLM|` key in `access`; this token authenticates
32
32
  * the Meta ACCOUNT and exists only to mint that key and to read subscription usage
33
- * (devlog/_plan/260912_muse_device_oauth/002 A). Keeping it out of `access` is what lets
33
+ * (devlog/_fin/260912_muse_device_oauth/002 A). Keeping it out of `access` is what lets
34
34
  * every request path stay unchanged.
35
35
  *
36
36
  * It must never be added to `OAuthAccountSummary` (src/oauth/index.ts:1803) or to
@@ -62,6 +62,8 @@ export type OAuthCredentials = {
62
62
  email?: string;
63
63
  accountId?: string;
64
64
  source?: OAuthCredentialSource;
65
+ /** Validated upstream subscription label; null is an observed unknown, absent is not read. */
66
+ plan?: string | null;
65
67
  /** Private authenticated account proof; never projected into account summaries. */
66
68
  anthropicIdentity?: { v: 1; accountUuid: string; bearerSha256: string };
67
69
  /** Google Antigravity (Cloud Code Assist) discovered project id; injected into the CCA envelope. */
@@ -121,8 +121,19 @@ export function macAclProbeTrustError(probe: () => MacAclProbeResult): string |
121
121
  }
122
122
  }
123
123
 
124
+ let pluginAclInspectorForTests: ((path: string) => string | null) | undefined;
125
+
126
+ /**
127
+ * Shipped mutable seam, test-only by intent: no config or env path reaches it.
128
+ * Passing undefined restores the real ACL inspector.
129
+ */
130
+ export function setPluginAclInspectorForTests(inspector?: (path: string) => string | null): void {
131
+ pluginAclInspectorForTests = inspector;
132
+ }
133
+
124
134
  /** Refuse extended ACLs: mode bits alone cannot prove who can rewrite a plugin path. */
125
135
  function aclTrustError(path: string): string | null {
136
+ if (pluginAclInspectorForTests) return pluginAclInspectorForTests(path);
126
137
  if (process.platform !== "darwin" && process.platform !== "linux") return null;
127
138
  const mac = process.platform === "darwin";
128
139
  // CI and operator PATHs may put GNU coreutils ahead of the macOS tool.
@@ -0,0 +1,74 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+ import { resolveAnthropicMessagesUrl } from "../adapters/anthropic";
3
+ import { providerApiKeySelectionIsCurrent } from "./api-key-selection";
4
+ import { hasKeyPoolFailover } from "./key-failover";
5
+ import type { OcxConfig } from "../types";
6
+ import { registerProviderResponseProvenance } from "./request-pacing";
7
+ import { isEgressTransparentExecutor, markEgressTransparentExecutor } from "../lib/provider-egress";
8
+
9
+ interface BillingSendOwnership {
10
+ readonly providerName: string;
11
+ readonly provider: OcxProviderConfig;
12
+ readonly target: string;
13
+ }
14
+ const responseOwners = new WeakMap<Response, BillingSendOwnership | null>();
15
+ registerProviderResponseProvenance(responseOwners);
16
+
17
+ /** Runs inside connection policy, after plugin/header rewrites, at the physical fetch. */
18
+ export function anthropicBillingPhysicalFetch(
19
+ physicalFetch: typeof globalThis.fetch,
20
+ providerName: string,
21
+ provider: OcxProviderConfig,
22
+ ): typeof globalThis.fetch {
23
+ const wrapped = (async (input, init) => {
24
+ const target = input instanceof Request ? input.url : String(input);
25
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
26
+ const selection = provider._apiKeyAttempt;
27
+ const ownsCredential = provider.apiKeyTransport === "bearer"
28
+ ? headers.get("authorization") === `Bearer ${provider.apiKey}` && !headers.has("x-api-key")
29
+ : headers.get("x-api-key") === provider.apiKey && !headers.has("authorization");
30
+ const ownsKey = provider.adapter === "anthropic" && (provider.authMode === undefined || provider.authMode === "key")
31
+ && hasKeyPoolFailover(provider) && selection !== undefined && provider.apiKey !== undefined
32
+ && ownsCredential
33
+ && target === resolveAnthropicMessagesUrl(provider);
34
+ const owner: BillingSendOwnership | null = ownsKey ? Object.freeze({
35
+ providerName, target,
36
+ provider: Object.freeze({ ...provider,
37
+ _apiKeyAttempt: Object.freeze({ ...selection }),
38
+ headers: Object.freeze({ ...provider.headers }),
39
+ apiKeyPool: provider.apiKeyPool?.map(entry => Object.freeze({ ...entry })),
40
+ }),
41
+ }) : null;
42
+ // No await between capturing the selected credential and invoking the actual send.
43
+ const response = await physicalFetch(input, init);
44
+ // An executor reusing a Response across physical sends cannot assign it two owners.
45
+ responseOwners.set(response, responseOwners.has(response) ? null : owner);
46
+ return response;
47
+ }) as typeof globalThis.fetch;
48
+ return isEgressTransparentExecutor(physicalFetch) ? markEgressTransparentExecutor(wrapped) : wrapped;
49
+ }
50
+
51
+ /** Immutable response ownership, matched to the attempted route and still-current selection. */
52
+ export function anthropicBillingResponseOwner(
53
+ response: Response, config: OcxConfig, providerName: string, attemptedProvider: OcxProviderConfig,
54
+ ): OcxProviderConfig | null {
55
+ const owner = responseOwners.get(response);
56
+ if (!owner || owner.providerName !== providerName || owner.target !== resolveAnthropicMessagesUrl(attemptedProvider)) return null;
57
+ const sent = owner.provider;
58
+ const current = config.providers[providerName];
59
+ if (!current || current.adapter !== sent.adapter || current.adapter !== "anthropic"
60
+ || (current.authMode !== undefined && current.authMode !== "key") || !hasKeyPoolFailover(current)) return null;
61
+ const sentTransport = sent.apiKeyTransport ?? "x-api-key";
62
+ if ((current.apiKeyTransport ?? "x-api-key") !== sentTransport
63
+ || (attemptedProvider.apiKeyTransport ?? "x-api-key") !== sentTransport) return null;
64
+ // Configured headers can change billing/account ownership independently of key selection.
65
+ const headerShape = (provider: OcxProviderConfig) => JSON.stringify(Object.entries(provider.headers ?? {}).sort(([a], [b]) => a.localeCompare(b)));
66
+ if (headerShape(current) !== headerShape(sent)) return null;
67
+ const attempted = attemptedProvider._apiKeyAttempt;
68
+ if (!attempted || attemptedProvider.apiKey !== sent.apiKey || attemptedProvider.baseUrl !== sent.baseUrl
69
+ || attemptedProvider.authMode !== sent.authMode || attemptedProvider.adapter !== sent.adapter
70
+ || attempted.entryId !== sent._apiKeyAttempt?.entryId || attempted.reference !== sent._apiKeyAttempt?.reference
71
+ || attempted.revision !== sent._apiKeyAttempt?.revision
72
+ || !providerApiKeySelectionIsCurrent(config, providerName, sent)) return null;
73
+ return sent;
74
+ }
@@ -0,0 +1,31 @@
1
+ import type { OcxConfig, OcxProviderConfig } from "../types";
2
+ import { isNonReplayableResponse } from "../lib/upstream-retry";
3
+ import { anthropicBillingResponseOwner } from "./anthropic-billing-ownership";
4
+ import { readAnthropicBillingRefusal } from "./anthropic-billing-refusal";
5
+ import { hasKeyPoolFailover, rotateProviderTransportOnAnthropicBilling } from "./key-failover";
6
+ import type { OcxProviderTransport } from "./xai-transport";
7
+
8
+ /**
9
+ * Whether a provider can recover a credit-balance refusal by key rotation. This is an
10
+ * eligibility predicate for send accounting, not a recovery site: the one billing recovery
11
+ * loop in adapter-dispatch.ts keeps its own `hasKeyPoolFailover` guard.
12
+ */
13
+ export function isAnthropicBillingKeyPool(provider: OcxProviderConfig): boolean {
14
+ return provider.adapter === "anthropic" && hasKeyPoolFailover(provider);
15
+ }
16
+
17
+ /** Pre-output billing recovery cools the refused key for the credit-balance window. */
18
+ export async function rotateAnthropicBillingKeyOnResponse(
19
+ response: Response, config: OcxConfig, providerName: string, attemptedProvider: OcxProviderConfig,
20
+ options: { signal?: AbortSignal; promptCacheKey?: string } = {},
21
+ ): Promise<OcxProviderTransport | null> {
22
+ if (options.signal?.aborted || isNonReplayableResponse(response)
23
+ || !anthropicBillingResponseOwner(response, config, providerName, attemptedProvider)) return null;
24
+ if (!await readAnthropicBillingRefusal(response, options.signal) || options.signal?.aborted) return null;
25
+ // Reading is asynchronous: fence a late refusal against manual selection or key replacement.
26
+ const owner = anthropicBillingResponseOwner(response, config, providerName, attemptedProvider);
27
+ if (!owner) return null;
28
+ return rotateProviderTransportOnAnthropicBilling(config, providerName, owner, {
29
+ attemptedKey: owner.apiKey, attemptedSelection: owner._apiKeyAttempt, promptCacheKey: options.promptCacheKey,
30
+ });
31
+ }
@@ -0,0 +1,55 @@
1
+ /** Pure credit-balance evidence, independent of OAuth account routing. */
2
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
+ const MAX_BILLING_REFUSAL_BYTES = 8 * 1024;
4
+ const BILLING_REFUSAL_DEADLINE_MS = 250;
5
+ // Keep the whole-message credit-balance pattern used by anthropic-account-refusal.
6
+ const CREDIT_BALANCE_REFUSAL = /^Your credit balance is too low to access the Anthropic API\.(?: Please go to Plans & Billing to upgrade or purchase credits\.)?$/i;
7
+ const BILLING_ERROR_TYPES = new Set(["invalid_request_error", "billing_error", "permission_error"]);
8
+ const ACCOUNT_REFUSAL_CODES = new Set(["subscription_required", "insufficient_quota", "permission_denied"]);
9
+
10
+ function record(value: unknown): value is Record<string, unknown> {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ /** Only an Anthropic error envelope with exact billing wording is evidence. */
15
+ export function isAnthropicBillingRefusal(status: number, payload: unknown): boolean {
16
+ if (status !== 400 && status !== 402) return false;
17
+ if (!record(payload) || payload.type !== "error" || !record(payload.error)) return false;
18
+ const error = payload.error;
19
+ if (typeof error.type !== "string" || !BILLING_ERROR_TYPES.has(error.type)
20
+ || typeof error.message !== "string" || Buffer.byteLength(error.message, "utf8") > MAX_BILLING_REFUSAL_BYTES) return false;
21
+ if (error.code != null && (typeof error.code !== "string" || !ACCOUNT_REFUSAL_CODES.has(error.code))) return false;
22
+ return CREDIT_BALANCE_REFUSAL.test(error.message.trim());
23
+ }
24
+
25
+ /** Bounded combo evidence after existing HTTP error formatting, before any output. */
26
+ export function isAnthropicBillingFailure(status: number, message: string, code?: string | null): boolean {
27
+ if (status !== 400 && status !== 402 || Buffer.byteLength(message, "utf8") > MAX_BILLING_REFUSAL_BYTES) return false;
28
+ if (code != null && !BILLING_ERROR_TYPES.has(code) && !ACCOUNT_REFUSAL_CODES.has(code)) return false;
29
+ let text = message.trim();
30
+ const prefix = `Provider error ${status}: `;
31
+ if (text.startsWith(prefix)) text = text.slice(prefix.length);
32
+ try { return isAnthropicBillingRefusal(status, JSON.parse(text)); } catch { /* Exact prose needs a structured type. */ }
33
+ return typeof code === "string" && BILLING_ERROR_TYPES.has(code)
34
+ && isAnthropicBillingRefusal(status, { type: "error", error: { type: code, message: text } });
35
+ }
36
+
37
+ /** Inspect a clone, never the terminal body; the shared reader owns interruption/cleanup. */
38
+ export async function readAnthropicBillingRefusal(response: Response, signal?: AbortSignal): Promise<boolean> {
39
+ if (signal?.aborted || (response.status !== 400 && response.status !== 402)) return false;
40
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase();
41
+ if (!contentType || !/^application\/(?:json|[a-z0-9.+-]+\+json)$/.test(contentType)) return false;
42
+ try {
43
+ const deadline = AbortSignal.timeout(BILLING_REFUSAL_DEADLINE_MS);
44
+ const body = await readBoundedResponseBody(response.clone(), {
45
+ signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
46
+ maxBytes: MAX_BILLING_REFUSAL_BYTES, fatalUtf8: true,
47
+ totalTimeoutMs: BILLING_REFUSAL_DEADLINE_MS,
48
+ });
49
+ if (signal?.aborted || !body.displaySafe || body.truncated || body.timedOut || body.oversized) return false;
50
+ return isAnthropicBillingRefusal(response.status, JSON.parse(body.text));
51
+ } catch {
52
+ // Untrusted malformed, unreadable and invalid UTF-8 bodies confer no authority.
53
+ return false;
54
+ }
55
+ }
@@ -12,7 +12,7 @@
12
12
  * account rate limit and must keep flowing into the existing wait/rotation handling, so only an
13
13
  * error message that names fast mode or the speed parameter, or a fast-pool header reporting
14
14
  * zero remaining, counts. Live probe strings (2026-09-23,
15
- * devlog/_plan/260923_anthropic_fast_speed/020_probe-evidence.md):
15
+ * devlog/_fin/260923_anthropic_fast_speed/020_probe-evidence.md):
16
16
  * 429 "Usage credits are required for fast mode."
17
17
  * 400 "Fast mode is not enabled for your organization. ..."
18
18
  * 400 "'<model>' does not support the `speed` parameter. ..."