@code-yeongyu/senpi 2026.11.3-1 → 2026.11.3-2

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 (201) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/bundle/chunks/anthropic-messages-LJAKRKMX.js +11 -0
  3. package/dist/bundle/chunks/{args-O3WS4WCO.js → args-GQTXDYS3.js} +1 -1
  4. package/dist/bundle/chunks/{azure-openai-responses-YLBBQXI7.js → azure-openai-responses-HDEKTPMZ.js} +1 -1
  5. package/dist/bundle/chunks/chatgpt-subscription.js +10 -2
  6. package/dist/bundle/chunks/{chunk-ED7VJGL4.js → chunk-2PGSRQBF.js} +1 -1
  7. package/dist/bundle/chunks/{chunk-4BF4FLT6.js → chunk-3LDASCJN.js} +1 -1
  8. package/dist/bundle/chunks/{chunk-PIAY636U.js → chunk-3QPE75AR.js} +1 -1
  9. package/dist/bundle/chunks/{chunk-FQZZGSYH.js → chunk-4ZGQUOBV.js} +1 -1
  10. package/dist/bundle/chunks/{chunk-S2M5D5GS.js → chunk-5BMIET5O.js} +2 -2
  11. package/dist/bundle/chunks/{chunk-XLLQJALL.js → chunk-67M7HKXO.js} +1 -1
  12. package/dist/bundle/chunks/chunk-6ISOXWH2.js +2 -0
  13. package/dist/bundle/chunks/{chunk-7KLEFHSU.js → chunk-6NPK2O3C.js} +2 -2
  14. package/dist/bundle/chunks/{chunk-BNIZP4OS.js → chunk-7C4W5HMU.js} +1 -1
  15. package/dist/bundle/chunks/chunk-7KIHKE2T.js +2 -0
  16. package/dist/bundle/chunks/{chunk-WCRNJYHO.js → chunk-A4KOQUHT.js} +1 -1
  17. package/dist/bundle/chunks/{chunk-TOOB6OLG.js → chunk-A5PZGQCP.js} +1 -1
  18. package/dist/bundle/chunks/{chunk-TBARMJ75.js → chunk-AVP6DDBF.js} +1 -1
  19. package/dist/bundle/chunks/{chunk-FLM6GZPT.js → chunk-B3GTID4P.js} +1 -1
  20. package/dist/bundle/chunks/{chunk-2XBKE2BZ.js → chunk-BIWFWGUA.js} +1 -1
  21. package/dist/bundle/chunks/{chunk-6LDUM5CL.js → chunk-DCI4QOUX.js} +1 -1
  22. package/dist/bundle/chunks/{chunk-2YFPDNNP.js → chunk-DNZKKG4V.js} +1 -1
  23. package/dist/bundle/chunks/{chunk-W2V7RIBF.js → chunk-E7SA75PP.js} +1 -1
  24. package/dist/bundle/chunks/{chunk-YWD54RHU.js → chunk-EBZ6IP4V.js} +1 -1
  25. package/dist/bundle/chunks/{chunk-J2OBN7TI.js → chunk-G5XUGK4B.js} +1 -1
  26. package/dist/bundle/chunks/{chunk-PX7FF5LE.js → chunk-GKP34H3C.js} +2 -2
  27. package/dist/bundle/chunks/{chunk-PP52OWBB.js → chunk-GLTOBXZY.js} +1 -1
  28. package/dist/bundle/chunks/{chunk-ZNLCKKF6.js → chunk-IJGXAACA.js} +117 -35
  29. package/dist/bundle/chunks/{chunk-LGO25HPL.js → chunk-JCTODLUP.js} +1 -1
  30. package/dist/bundle/chunks/{chunk-3ESZE3DG.js → chunk-KHBCZ6TR.js} +1 -1
  31. package/dist/bundle/chunks/chunk-KJR3XXMI.js +2 -0
  32. package/dist/bundle/chunks/{chunk-FJ2UCBOA.js → chunk-KY4V3TSO.js} +2 -2
  33. package/dist/bundle/chunks/{chunk-4SENPCEA.js → chunk-MCXOJHN2.js} +1 -1
  34. package/dist/bundle/chunks/{chunk-DPBPO5OU.js → chunk-NKI7L5UM.js} +3 -3
  35. package/dist/bundle/chunks/{chunk-CJWSQ6CK.js → chunk-OD7RT7G7.js} +1 -1
  36. package/dist/bundle/chunks/{chunk-F4OHTL2K.js → chunk-OOU3BRUY.js} +1 -1
  37. package/dist/bundle/chunks/{chunk-LPHUMR6R.js → chunk-OT6ALXSJ.js} +1 -1
  38. package/dist/bundle/chunks/{chunk-XGBRBCIQ.js → chunk-Q5CKFBQT.js} +1 -1
  39. package/dist/bundle/chunks/{chunk-F7GBSQRV.js → chunk-QPYDQ4QK.js} +1 -1
  40. package/dist/bundle/chunks/{chunk-MOOFLKLI.js → chunk-QSMT5KVJ.js} +1 -1
  41. package/dist/bundle/chunks/{chunk-SAZ6E74O.js → chunk-RUOTFZQR.js} +1 -1
  42. package/dist/bundle/chunks/{chunk-5DZD623T.js → chunk-RWF6IJQL.js} +2 -2
  43. package/dist/bundle/chunks/{chunk-HBCNDC53.js → chunk-S5Y7NEHB.js} +1 -1
  44. package/dist/bundle/chunks/{chunk-DAPBNRN5.js → chunk-SCBTASM4.js} +1 -1
  45. package/dist/bundle/chunks/chunk-SJWTZ3Z2.js +2 -0
  46. package/dist/bundle/chunks/{chunk-ISZ4EWIS.js → chunk-SODMQEHO.js} +1 -1
  47. package/dist/bundle/chunks/{chunk-WEGTCUO3.js → chunk-SQAQEITV.js} +1 -1
  48. package/dist/bundle/chunks/{chunk-UFVZTQKN.js → chunk-TQQG5L4D.js} +1 -1
  49. package/dist/bundle/chunks/{chunk-VPDQ6MOL.js → chunk-U6A4X3FV.js} +1 -1
  50. package/dist/bundle/chunks/{chunk-ACIFXWJ6.js → chunk-UHUM55TR.js} +1 -1
  51. package/dist/bundle/chunks/{chunk-L5OFGHDA.js → chunk-USKWGPA6.js} +1 -1
  52. package/dist/bundle/chunks/{chunk-7ORS33CE.js → chunk-VDDJADPV.js} +1 -1
  53. package/dist/bundle/chunks/{chunk-R65P6LAT.js → chunk-VIHXG53S.js} +1 -1
  54. package/dist/bundle/chunks/{chunk-SGS76663.js → chunk-VPVD5G32.js} +1 -1
  55. package/dist/bundle/chunks/chunk-WO6I5PRF.js +3 -0
  56. package/dist/bundle/chunks/{chunk-ETCYVVVO.js → chunk-WVEIJ437.js} +1 -1
  57. package/dist/bundle/chunks/{chunk-7565XQAU.js → chunk-XKFG4N3Z.js} +1 -1
  58. package/dist/bundle/chunks/{chunk-57MRFI6E.js → chunk-XMWPCJU6.js} +1 -1
  59. package/dist/bundle/chunks/{chunk-GDPWQJDQ.js → chunk-XPSXUKSD.js} +1 -1
  60. package/dist/bundle/chunks/{chunk-FNBEICAA.js → chunk-YI6KRJDC.js} +1 -1
  61. package/dist/bundle/chunks/{chunk-UURGXV5B.js → chunk-YP47G4YP.js} +1 -1
  62. package/dist/bundle/chunks/{chunk-6BSMXZKQ.js → chunk-YRYDCKUI.js} +1 -1
  63. package/dist/bundle/chunks/{chunk-QTQ3ES2S.js → chunk-YTKZSGMQ.js} +1 -1
  64. package/dist/bundle/chunks/{chunk-V36CXIIB.js → chunk-YXL3PNTL.js} +1 -1
  65. package/dist/bundle/chunks/chunk-Z7HTHT6K.js +2 -0
  66. package/dist/bundle/chunks/{chunk-27XEDYQA.js → chunk-ZTPG27EJ.js} +1 -1
  67. package/dist/bundle/chunks/{cli-main-XM5FWY3Z.js → cli-main-RN7YB427.js} +1 -1
  68. package/dist/bundle/chunks/{cloudflare-workers-ai-system-one-FC7QLRU2.js → cloudflare-workers-ai-system-one-N5KFNKK4.js} +1 -1
  69. package/dist/bundle/chunks/credential-accounts-BJX55MTT.js +2 -0
  70. package/dist/bundle/chunks/devin.js +10 -2
  71. package/dist/bundle/chunks/{dispatch-UY6LBJJE.js → dispatch-M7UAPGVY.js} +1 -1
  72. package/dist/bundle/chunks/{google-generative-ai-7PQ4QRHJ.js → google-generative-ai-2TZSCSIS.js} +1 -1
  73. package/dist/bundle/chunks/{google-vertex-DY7EO4EJ.js → google-vertex-7NJJOI7V.js} +1 -1
  74. package/dist/bundle/chunks/{health-write-45CX64T2.js → health-write-YUSPVH7U.js} +1 -1
  75. package/dist/bundle/chunks/{help-fast-path-3EDGRKQM.js → help-fast-path-DWCA77QP.js} +1 -1
  76. package/dist/bundle/chunks/host-command-KJXUTSQ7.js +2 -0
  77. package/dist/bundle/chunks/{host-lifecycle-TGG7KGQH.js → host-lifecycle-QHIJ4G2K.js} +1 -1
  78. package/dist/bundle/chunks/host-lifecycle.js +1 -1
  79. package/dist/bundle/chunks/{host-session-command-OJFARE5Q.js → host-session-command-IPH3WXSG.js} +1 -1
  80. package/dist/bundle/chunks/{host-socket-request-F2R5P34M.js → host-socket-request-XPXJV3CS.js} +1 -1
  81. package/dist/bundle/chunks/interactive-mode-XUXMDYLA.js +2 -0
  82. package/dist/bundle/chunks/{list-tips-3D3A6GFK.js → list-tips-XJY7EL5S.js} +1 -1
  83. package/dist/bundle/chunks/{login-health-YOPTYRTU.js → login-health-226NO44U.js} +1 -1
  84. package/dist/bundle/chunks/main-P7PKMJUG.js +2 -0
  85. package/dist/bundle/chunks/{mistral-conversations-6F2RAIB2.js → mistral-conversations-QMHMHSUF.js} +1 -1
  86. package/dist/bundle/chunks/{multi-session-host-N2RQ7JSZ.js → multi-session-host-ZQY4AN46.js} +1 -1
  87. package/dist/bundle/chunks/{native-loader-NMDTMHMA.js → native-loader-A6DNMGCK.js} +1 -1
  88. package/dist/bundle/chunks/openai-chatgpt.js +11 -3
  89. package/dist/bundle/chunks/{openai-codex-responses-YTWLG4OL.js → openai-codex-responses-GUAHKQE2.js} +1 -1
  90. package/dist/bundle/chunks/{openai-completions-TIFDYHZL.js → openai-completions-UPRTFM54.js} +1 -1
  91. package/dist/bundle/chunks/{openai-images-OAEMY4RG.js → openai-images-IKWNS6VB.js} +1 -1
  92. package/dist/bundle/chunks/{openai-responses-L35NIG5Y.js → openai-responses-AXN5NUHH.js} +1 -1
  93. package/dist/bundle/chunks/openrouter.js +10 -2
  94. package/dist/bundle/chunks/{package-manager-cli-JPNA27E5.js → package-manager-cli-PP7L2Q5M.js} +1 -1
  95. package/dist/bundle/chunks/radius.js +10 -2
  96. package/dist/bundle/chunks/{rg-engine-JDUYBM6L.js → rg-engine-VRSXT6HG.js} +1 -1
  97. package/dist/bundle/chunks/{role-45Y7PQFC.js → role-L5J7ZPCF.js} +1 -1
  98. package/dist/bundle/chunks/rotation-stream-6INZXQFC.js +2 -0
  99. package/dist/bundle/chunks/rpc-mode-W4UATRAT.js +2 -0
  100. package/dist/bundle/chunks/{rpc-monitor-source-5TUR27NY.js → rpc-monitor-source-RMMUPBMS.js} +1 -1
  101. package/dist/bundle/chunks/{run-attached-tui-TI6MELEI.js → run-attached-tui-RLXFGCEP.js} +1 -1
  102. package/dist/bundle/chunks/{schedule-command-OZ5ESMIT.js → schedule-command-ZNFEJNTT.js} +1 -1
  103. package/dist/bundle/chunks/{session-control-endpoint-5YKBYJOO.js → session-control-endpoint-TWTONJWF.js} +1 -1
  104. package/dist/bundle/chunks/{session-picker-CMRV72VA.js → session-picker-KRUVCHOV.js} +1 -1
  105. package/dist/bundle/chunks/session-slot-NJOZCOHQ.js +2 -0
  106. package/dist/bundle/chunks/session-worker.js +218 -135
  107. package/dist/bundle/chunks/{state-store-Q47AYMRI.js → state-store-ZAFZ4JOM.js} +1 -1
  108. package/dist/bundle/chunks/{typesafe-system-one-5JYDJK6S.js → typesafe-system-one-CICIRAYN.js} +1 -1
  109. package/dist/bundle/cli.js +2 -2
  110. package/dist/bundle/host-supervisor.js +1 -1
  111. package/dist/bundle/index.js +1 -1
  112. package/dist/bundle/rpc-entry.js +1 -1
  113. package/dist/bundle/runtime-manifest.json +1 -1
  114. package/dist/core/agent-session.js +5 -3
  115. package/dist/core/auth-storage.d.ts +19 -0
  116. package/dist/core/auth-storage.js +99 -24
  117. package/dist/core/auth-store-load-error.d.ts +29 -0
  118. package/dist/core/auth-store-load-error.js +74 -0
  119. package/dist/core/brand.d.ts +2 -0
  120. package/dist/core/brand.js +1 -0
  121. package/dist/core/credential-pool/failover.d.ts +8 -0
  122. package/dist/core/credential-pool/failover.js +18 -5
  123. package/dist/core/credential-pool/rotation-stream.d.ts +1 -1
  124. package/dist/core/credential-pool/rotation-stream.js +2 -7
  125. package/dist/core/credential-pool/session-slot.d.ts +20 -0
  126. package/dist/core/credential-pool/session-slot.js +26 -0
  127. package/dist/core/credential-recovery-revision.d.ts +10 -0
  128. package/dist/core/credential-recovery-revision.js +19 -0
  129. package/dist/core/execution/executor-body.d.ts +22 -0
  130. package/dist/core/execution/executor-body.js +73 -0
  131. package/dist/core/execution/executor-freeze.d.ts +17 -0
  132. package/dist/core/execution/executor-freeze.js +53 -0
  133. package/dist/core/execution/executor-pipeline.d.ts +9 -0
  134. package/dist/core/execution/executor-pipeline.js +25 -0
  135. package/dist/core/execution/executor-types.d.ts +82 -0
  136. package/dist/core/execution/executor-types.js +2 -0
  137. package/dist/core/execution/executor.d.ts +8 -0
  138. package/dist/core/execution/executor.js +216 -0
  139. package/dist/core/extensions/builtin/anthropic-subscription/account-block-log.d.ts +15 -0
  140. package/dist/core/extensions/builtin/anthropic-subscription/account-block-log.js +18 -0
  141. package/dist/core/extensions/builtin/anthropic-subscription/affinity.d.ts +9 -1
  142. package/dist/core/extensions/builtin/anthropic-subscription/affinity.js +15 -4
  143. package/dist/core/extensions/builtin/anthropic-subscription/failover.d.ts +7 -1
  144. package/dist/core/extensions/builtin/anthropic-subscription/failover.js +19 -2
  145. package/dist/core/extensions/builtin/anthropic-subscription/guidance.d.ts +8 -2
  146. package/dist/core/extensions/builtin/anthropic-subscription/guidance.js +25 -1
  147. package/dist/core/extensions/builtin/anthropic-subscription/provider-diagnostic.d.ts +9 -0
  148. package/dist/core/extensions/builtin/anthropic-subscription/provider-diagnostic.js +59 -0
  149. package/dist/core/extensions/builtin/anthropic-subscription/session-registry-wiring.js +7 -0
  150. package/dist/core/extensions/builtin/anthropic-subscription/stream-guidance.js +5 -3
  151. package/dist/core/extensions/builtin/anthropic-subscription/stream.js +4 -0
  152. package/dist/core/extensions/builtin/btw/index.js +3 -1
  153. package/dist/core/extensions/builtin/cache-keepalive/index.js +3 -1
  154. package/dist/core/extensions/builtin/compaction/index.js +1 -1
  155. package/dist/core/extensions/builtin/compaction/openai-remote.d.ts +3 -1
  156. package/dist/core/extensions/builtin/compaction/openai-remote.js +1 -1
  157. package/dist/core/extensions/builtin/compaction/speculative.js +3 -2
  158. package/dist/core/extensions/builtin/cursor-cli-oauth/account-command.js +3 -1
  159. package/dist/core/extensions/builtin/cursor-cli-oauth/native-bootstrap.js +3 -1
  160. package/dist/core/extensions/builtin/look-at/runner.js +3 -1
  161. package/dist/core/extensions/builtin/mcp/auth/callback.js +3 -4
  162. package/dist/core/extensions/builtin/mcp/auth/oauth-provider.js +2 -1
  163. package/dist/core/model-registry.d.ts +10 -2
  164. package/dist/core/model-registry.js +12 -4
  165. package/dist/core/model-runtime.d.ts +8 -0
  166. package/dist/core/model-runtime.js +38 -0
  167. package/dist/core/retry-fallback/controller-types.d.ts +7 -0
  168. package/dist/core/retry-fallback/controller.d.ts +2 -0
  169. package/dist/core/retry-fallback/controller.js +13 -4
  170. package/dist/core/retry-fallback/credential-recovery.d.ts +18 -0
  171. package/dist/core/retry-fallback/credential-recovery.js +32 -0
  172. package/dist/core/retry-fallback/settings.d.ts +8 -1
  173. package/dist/core/retry-fallback/settings.js +12 -6
  174. package/dist/core/session-log.js +1 -1
  175. package/dist/core/settings-manager.js +4 -1
  176. package/dist/modes/rpc/connection-handler.js +3 -1
  177. package/dist/modes/rpc/effect-commands.d.ts +1 -0
  178. package/dist/modes/rpc/loopless-execute-grant.d.ts +20 -0
  179. package/dist/modes/rpc/loopless-execute-grant.js +26 -0
  180. package/dist/modes/rpc/loopless-runtime-settings.d.ts +30 -0
  181. package/dist/modes/rpc/loopless-runtime-settings.js +69 -0
  182. package/dist/modes/rpc/loopless-tool-call.d.ts +12 -0
  183. package/dist/modes/rpc/loopless-tool-call.js +22 -0
  184. package/dist/modes/rpc/rpc-read-response-types.d.ts +2 -1
  185. package/dist/modes/rpc/rpc-session-state-types.d.ts +10 -0
  186. package/dist/modes/rpc/rpc-types.d.ts +1 -1
  187. package/dist/modes/rpc/runtime-factory-loopless.d.ts +57 -0
  188. package/dist/modes/rpc/runtime-factory-loopless.js +131 -0
  189. package/docs/development.md +1 -1
  190. package/docs/rpc.md +2 -0
  191. package/package.json +6 -6
  192. package/dist/bundle/chunks/anthropic-messages-AFWQB3KN.js +0 -10
  193. package/dist/bundle/chunks/chunk-3WJ2DE2G.js +0 -3
  194. package/dist/bundle/chunks/chunk-ESQBKTNG.js +0 -2
  195. package/dist/bundle/chunks/chunk-MOH5HOHX.js +0 -2
  196. package/dist/bundle/chunks/credential-accounts-YZYSKA7X.js +0 -2
  197. package/dist/bundle/chunks/host-command-PZ4VYQIW.js +0 -2
  198. package/dist/bundle/chunks/interactive-mode-7JSJVVIS.js +0 -2
  199. package/dist/bundle/chunks/main-3YGJ2O4I.js +0 -2
  200. package/dist/bundle/chunks/rotation-stream-WXWJKJZN.js +0 -2
  201. package/dist/bundle/chunks/rpc-mode-Z7MN2ZWA.js +0 -2
@@ -11,7 +11,29 @@ export function noAccountGuidance(hasAnthropicCredential) {
11
11
  lines.push(" Or set CLAUDE_CODE_OAUTH_TOKEN (and _2.._N for more accounts),", " or log in with the claude CLI for ambient auth.");
12
12
  return lines.join("\n");
13
13
  }
14
- export function allAccountsBlockedGuidance(soonestUnblockAt, blockReason) {
14
+ /** One line per pool member, naming the account that needs /login (senpi#4671). */
15
+ function accountStateLines(accounts) {
16
+ return accounts.map((account) => {
17
+ const until = account.until === undefined ? "re-login" : new Date(account.until).toISOString();
18
+ const login = account.reason === "auth_error" ? ` - run /login ${PROVIDER} for this account` : "";
19
+ return ` ${account.name}: ${account.reason} until ${until}${login}`;
20
+ });
21
+ }
22
+ /**
23
+ * The pool context of a failure that left no other account (senpi#4671): the failure stays the error, and this
24
+ * names the soonest retry (which goal mode reads) and every account's state. No refusal note: the pool did not
25
+ * refuse the request, the account failed it.
26
+ */
27
+ export function exhaustedPoolGuidance(soonestUnblockAt, accounts) {
28
+ const eta = soonestUnblockAt !== undefined && Number.isFinite(soonestUnblockAt)
29
+ ? new Date(soonestUnblockAt).toISOString()
30
+ : "after re-login";
31
+ return [
32
+ ` No other Claude account is available. Soonest automatic retry: ${eta}.`,
33
+ ...accountStateLines(accounts),
34
+ ].join("\n");
35
+ }
36
+ export function allAccountsBlockedGuidance(soonestUnblockAt, blockReason, accounts = []) {
15
37
  if (blockReason === "auth_error") {
16
38
  // omo#8383: name the authentication failure; the recovery is a re-login. Only a pool whose every
17
39
  // account is auth-blocked says so (senpi#2081), and the outer credential pool reads the refusal note
@@ -22,6 +44,7 @@ export function allAccountsBlockedGuidance(soonestUnblockAt, blockReason) {
22
44
  return [
23
45
  `All Claude accounts for ${PROVIDER} are currently blocked (authentication error).`,
24
46
  ...recheck,
47
+ ...accountStateLines(accounts),
25
48
  ` /login ${PROVIDER} - re-authenticate to refresh the blocked account`,
26
49
  ` /claude-account list - inspect account states`,
27
50
  ACCOUNT_POOL_REFUSAL_NOTE,
@@ -33,6 +56,7 @@ export function allAccountsBlockedGuidance(soonestUnblockAt, blockReason) {
33
56
  return [
34
57
  `All Claude accounts for ${PROVIDER} are currently blocked (rate limit or auth errors).`,
35
58
  ` Soonest automatic retry: ${eta}.`,
59
+ ...accountStateLines(accounts),
36
60
  ` /claude-account list - inspect account states`,
37
61
  ` /login ${PROVIDER} - add another account`,
38
62
  ACCOUNT_POOL_REFUSAL_NOTE,
@@ -0,0 +1,9 @@
1
+ import { type ProviderDiagnostic } from "@earendil-works/pi-ai";
2
+ /**
3
+ * The providerDiagnostic for an Anthropic Subscription turn failure (senpi#4672), mirroring the
4
+ * chatgpt-subscription adapter (senpi#3771). A pool the extension refused locally is `auth` when every
5
+ * account waits on a new login, otherwise `rate_limit` with the soonest retry; a classified SDK failure maps
6
+ * to its provider token. The result is revalidated against the closed vocabulary, so nothing else escapes.
7
+ */
8
+ export declare function anthropicSubscriptionDiagnostic(error: unknown): ProviderDiagnostic | undefined;
9
+ //# sourceMappingURL=provider-diagnostic.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { sanitizeProviderDiagnostic, } from "@earendil-works/pi-ai";
2
+ import { isThinkingBindingMismatchText } from "@earendil-works/pi-ai/api/anthropic-thinking-binding";
3
+ import { AllAccountsBlockedError } from "./affinity.js";
4
+ import { classifySdkError } from "./errors.js";
5
+ import { ClassifiedSdkError } from "./failover.js";
6
+ /** The allowlisted provider token each classified SDK failure proves; kinds that prove nothing map to none. */
7
+ const SDK_KIND_DIAGNOSTICS = {
8
+ auth_error: { category: "auth", code: "authentication_error" },
9
+ rate_limit: { category: "rate_limit", code: "rate_limit_error" },
10
+ overloaded: { category: "provider_unavailable", code: "overloaded_error" },
11
+ billing: { category: "unknown", code: "billing_error" },
12
+ org_not_allowed: { category: "unknown", code: "permission_error" },
13
+ };
14
+ const CAUSE_DEPTH = 8;
15
+ /** The pool refusal behind `error`: the error itself or one it carries as `cause`, so wrapping never hides it. */
16
+ function poolRefusal(error) {
17
+ let current = error;
18
+ for (let depth = 0; depth < CAUSE_DEPTH && current instanceof Error; depth++) {
19
+ if (current instanceof AllAccountsBlockedError)
20
+ return current;
21
+ current = current.cause;
22
+ }
23
+ return undefined;
24
+ }
25
+ /**
26
+ * The providerDiagnostic for an Anthropic Subscription turn failure (senpi#4672), mirroring the
27
+ * chatgpt-subscription adapter (senpi#3771). A pool the extension refused locally is `auth` when every
28
+ * account waits on a new login, otherwise `rate_limit` with the soonest retry; a classified SDK failure maps
29
+ * to its provider token. The result is revalidated against the closed vocabulary, so nothing else escapes.
30
+ */
31
+ export function anthropicSubscriptionDiagnostic(error) {
32
+ const pool = poolRefusal(error);
33
+ if (pool !== undefined) {
34
+ // A pin to an account that is no longer configured is a configuration error, not a limit: no diagnostic.
35
+ if (pool.unavailableAccount?.configured === false)
36
+ return undefined;
37
+ const auth = pool.blockReason === "auth_error";
38
+ return sanitizeProviderDiagnostic({
39
+ category: auth ? "auth" : "rate_limit",
40
+ code: auth ? "account_pool_auth_blocked" : "account_pool_blocked",
41
+ evidence: "structured_code",
42
+ ...(auth || pool.soonestUnblockAt === undefined ? {} : { resetsAt: pool.soonestUnblockAt }),
43
+ });
44
+ }
45
+ const original = error instanceof ClassifiedSdkError ? error.original : error;
46
+ if (original instanceof Error && isThinkingBindingMismatchText(original.message)) {
47
+ // senpi#5587: the API refused a replayed thinking block bound to a different conversation.
48
+ return sanitizeProviderDiagnostic({
49
+ category: "invalid_request",
50
+ httpStatus: 400,
51
+ code: "invalid_request_error",
52
+ evidence: "structured_code",
53
+ });
54
+ }
55
+ const kind = error instanceof ClassifiedSdkError ? error.classification.kind : classifySdkError(error).kind;
56
+ const mapped = SDK_KIND_DIAGNOSTICS[kind];
57
+ return mapped === undefined ? undefined : sanitizeProviderDiagnostic({ ...mapped, evidence: "structured_code" });
58
+ }
59
+ //# sourceMappingURL=provider-diagnostic.js.map
@@ -178,6 +178,13 @@ export function registerSessionRegistry(pi) {
178
178
  await writeStoredBinding(sessionFile, stored);
179
179
  });
180
180
  pi.on("session_shutdown", (event, ctx) => {
181
+ // A config hot-reload keeps the session and this module's registry (session_start skips
182
+ // "reload" above), and the resident query captures no per-load state: its hooks and tool
183
+ // handlers are module constants. The next turn's fingerprint check restarts the process
184
+ // only when the reload changed the prompt, tools or options. Closing here re-spawned Claude
185
+ // Code on every reload, and on a real account re-attached the claude.ai connectors.
186
+ if (event.reason === "reload")
187
+ return;
181
188
  closeSession(ctx.sessionManager.getSessionId(), event.reason);
182
189
  });
183
190
  pi.on("session_extensions_removed", async (_event, ctx) => {
@@ -2,7 +2,7 @@ import { AllAccountsBlockedError } from "./affinity.js";
2
2
  import { errorAccountContext } from "./account-context.js";
3
3
  import { classifySdkError } from "./errors.js";
4
4
  import { ClassifiedSdkError } from "./failover.js";
5
- import { allAccountsBlockedGuidance, claudeCodeVersionFloorGuidance, sdkErrorGuidance, singleAccountUnavailableGuidance, switchAccountLine, } from "./guidance.js";
5
+ import { allAccountsBlockedGuidance, claudeCodeVersionFloorGuidance, exhaustedPoolGuidance, sdkErrorGuidance, singleAccountUnavailableGuidance, switchAccountLine, } from "./guidance.js";
6
6
  import { ClaudeSignInRequiredError } from "./sign-in-again.js";
7
7
  export function withAuthGuidance(error, message, ran, now = Date.now()) {
8
8
  // The sign-in-again prompt is the remedy; the generic auth_error hint ("token was rejected... refresh") contradicts it.
@@ -10,7 +10,7 @@ export function withAuthGuidance(error, message, ran, now = Date.now()) {
10
10
  return message;
11
11
  if (error instanceof AllAccountsBlockedError) {
12
12
  const guidance = error.unavailableAccount === undefined
13
- ? allAccountsBlockedGuidance(error.soonestUnblockAt, error.blockReason)
13
+ ? allAccountsBlockedGuidance(error.soonestUnblockAt, error.blockReason, error.accounts)
14
14
  : singleAccountUnavailableGuidance(error.unavailableAccount, error.soonestUnblockAt, error.blockReason);
15
15
  // The model-limit sentence is how the fallback chain learns the limit binds this model only (senpi#5007).
16
16
  return error.limitedModel === undefined
@@ -28,7 +28,9 @@ export function withAuthGuidance(error, message, ran, now = Date.now()) {
28
28
  guidance = `${selected} ${action}`;
29
29
  }
30
30
  const versionGuidance = claudeCodeVersionFloorGuidance(message, ran);
31
- const hints = [guidance, versionGuidance].filter((hint) => hint !== undefined);
31
+ const pool = error instanceof ClassifiedSdkError ? error.poolState : undefined;
32
+ const poolGuidance = pool && exhaustedPoolGuidance(pool.soonestUnblockAt, pool.accounts);
33
+ const hints = [guidance, versionGuidance, poolGuidance].filter((hint) => hint !== undefined);
32
34
  return hints.length > 0 ? `${message}\n${hints.join("\n")}` : message;
33
35
  }
34
36
  /**
@@ -13,6 +13,7 @@ import { refusalError } from "./refusal.js";
13
13
  import { getSdkBoundary, loadClaudeAgentSdk } from "./sdk-boundary.js";
14
14
  import { emitContinuityObservation } from "./session-observability.js";
15
15
  import { forgetBinding } from "./session-reattach.js";
16
+ import { anthropicSubscriptionDiagnostic } from "./provider-diagnostic.js";
16
17
  import { requireAnthropicSubscriptionCwd } from "./session-cwd.js";
17
18
  import { residentSessionMessages } from "./session-stream.js";
18
19
  import { loadAnthropicSubscriptionProviderSettingsFromDisk, resolveCompactionOwner, resumeModeSource, } from "./settings.js";
@@ -268,6 +269,9 @@ export function streamAnthropicSubscription(model, context, options) {
268
269
  if (billed)
269
270
  updateUsage(model, output, billed, "query");
270
271
  output.errorMessage = withAuthGuidance(error, errorMessage(error), claudeCodeRun);
272
+ const diagnostic = output.stopReason === "error" ? anthropicSubscriptionDiagnostic(error) : undefined;
273
+ if (diagnostic !== undefined)
274
+ output.providerDiagnostic = diagnostic;
271
275
  markColdSeedOverflow(output, model, coldSeedAttempt, coldSeedEstimate, options?.sessionId);
272
276
  stream.push({ type: "error", reason: output.stopReason, error: output });
273
277
  }
@@ -78,7 +78,9 @@ export default function btwExtension(pi) {
78
78
  return undefined;
79
79
  });
80
80
  }
81
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
81
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model, {
82
+ sessionId: ctx.sessionManager.getSessionId(),
83
+ });
82
84
  if (!auth.ok) {
83
85
  if (active !== entry)
84
86
  return;
@@ -136,7 +136,9 @@ export function createCacheKeepAliveExtension(dependencies = {}) {
136
136
  .getAllTools()
137
137
  .filter((tool) => activeToolNames.has(tool.name))
138
138
  .map(({ name, description, parameters }) => ({ name, description, parameters }));
139
- const auth = await current.modelRegistry.getApiKeyAndHeaders(current.model);
139
+ const auth = await current.modelRegistry.getApiKeyAndHeaders(current.model, {
140
+ sessionId: current.sessionManager.getSessionId(),
141
+ });
140
142
  if (pingGeneration !== generation) {
141
143
  inFlight = false;
142
144
  arm();
@@ -869,7 +869,7 @@ export default function compactionExtension(pi, remoteCompactionDependencies = {
869
869
  const model = event.model ?? ctx.model;
870
870
  if (!isOpenAiRemoteCompactionModel(model))
871
871
  return undefined;
872
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
872
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model, { sessionId: ctx.sessionManager.getSessionId() });
873
873
  if (!auth.ok)
874
874
  return undefined;
875
875
  const effectiveModel = auth.upstreamModelId || auth.baseUrl
@@ -36,7 +36,9 @@ type OpenAiRemoteCompactionContext = {
36
36
  getSystemPrompt(): string;
37
37
  model: Model<Api> | undefined;
38
38
  modelRegistry: {
39
- getApiKeyAndHeaders(model: Model<Api>): Promise<{
39
+ getApiKeyAndHeaders(model: Model<Api>, options?: {
40
+ sessionId?: string;
41
+ }): Promise<{
40
42
  ok: true;
41
43
  apiKey?: string;
42
44
  headers?: ProviderHeaders;
@@ -335,7 +335,7 @@ export async function runOpenAiRemoteCompaction(ctx, event, emit, dependencies =
335
335
  });
336
336
  return undefined;
337
337
  }
338
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
338
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model, { sessionId: ctx.sessionManager.getSessionId() });
339
339
  if (!auth.ok) {
340
340
  emit?.({
341
341
  version: 1,
@@ -146,15 +146,16 @@ export async function runExtensionCompaction(context, snapshot, signal, onProgre
146
146
  // same provider resolution normal turns use; an unconfigured keyed provider still
147
147
  // has none of these and is rejected before a request.
148
148
  const authenticated = (resolved) => resolved?.ok === true && Boolean(resolved.apiKey || hasCredentialHeaders(resolved.headers) || resolved.ambient);
149
+ const session = { sessionId: context.sessionManager.getSessionId() };
149
150
  let selectedModel = context.getCompactionModel?.() ?? snapshot.model;
150
- let auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel);
151
+ let auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel, session);
151
152
  if (signal?.aborted)
152
153
  return undefined;
153
154
  // A configured summary model whose credentials could not be confirmed earlier (#830) must not
154
155
  // disable compaction: without credentials at request time, summarize on the session model.
155
156
  if (selectedModel !== snapshot.model && !authenticated(auth)) {
156
157
  selectedModel = snapshot.model;
157
- auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel);
158
+ auth = await context.modelRegistry?.getApiKeyAndHeaders(selectedModel, session);
158
159
  if (signal?.aborted)
159
160
  return undefined;
160
161
  }
@@ -1,3 +1,4 @@
1
+ import { stampLogin } from "@earendil-works/pi-ai/auth/pool/login-id";
1
2
  import { accountLabel } from "@earendil-works/pi-ai/auth/pool/slots";
2
3
  import { describeAccountBlock } from "../../../credential-account-status.js";
3
4
  import { emitProviderAccountsChanged } from "../anthropic-subscription/account-events.js";
@@ -248,7 +249,8 @@ async function importAccount(ctx, deps, source) {
248
249
  : await importCredential(before, importDeps);
249
250
  const existing = new Set(listAccounts(before).map((account) => account.name));
250
251
  importedName = listAccounts(updated).find((account) => !existing.has(account.name))?.name;
251
- return updated;
252
+ // An import adds a signed-in account, so it stamps a login id like a login (senpi#4099).
253
+ return stampLogin(updated);
252
254
  });
253
255
  }
254
256
  catch (error) {
@@ -1,3 +1,4 @@
1
+ import { stampLogin } from "@earendil-works/pi-ai/auth/pool/login-id";
1
2
  import { emptyCredential, listAccounts, SENTINEL_OAUTH_FIELDS } from "./accounts.js";
2
3
  import { CURSOR_CLI_OAUTH_PROVIDER_ID, importNativeCursorCredential } from "./oauth-login.js";
3
4
  function asManagedCredential(credential) {
@@ -31,7 +32,8 @@ async function bootstrapNativeCredential(deps) {
31
32
  return latest;
32
33
  if (latest !== undefined && !latestManaged)
33
34
  return latest;
34
- return importNativeCursorCredential(latestManaged ?? emptyCredential(), async () => native);
35
+ // The first-run copy adds a signed-in account, so it stamps a login id like a login (senpi#4099).
36
+ return stampLogin(await importNativeCursorCredential(latestManaged ?? emptyCredential(), async () => native));
35
37
  });
36
38
  }
37
39
  catch {
@@ -10,7 +10,9 @@ export async function runLookAt(args, signal, ctx, store, dependencies = {}) {
10
10
  if (!resolved) {
11
11
  throw new Error("No image-capable model is available. Configure a vision-capable provider and try look_at again.");
12
12
  }
13
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(resolved.model);
13
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(resolved.model, {
14
+ sessionId: ctx.sessionManager.getSessionId(),
15
+ });
14
16
  if (!auth.ok) {
15
17
  throw new Error(`look_at cannot use ${resolved.model.provider}/${resolved.model.id}: ${auth.error}. ` +
16
18
  `Configure credentials with /login ${resolved.model.provider} and try again.`);
@@ -1,4 +1,5 @@
1
1
  import { createServer } from "node:http";
2
+ import { oauthErrorHtml, oauthSuccessHtml } from "@earendil-works/pi-ai/utils/oauth-page";
2
3
  import { safeTimer } from "../wrap.js";
3
4
  import { OAuthFlowError } from "./oauth-errors.js";
4
5
  // Chooses the redirect channel for an authorization: a real loopback listener,
@@ -23,8 +24,6 @@ export async function openCallbackChannel(options) {
23
24
  };
24
25
  }
25
26
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
26
- const SUCCESS_HTML = "<!doctype html><title>senpi</title><p>Authorization complete. You can close this tab.</p>";
27
- const FAILURE_HTML = "<!doctype html><title>senpi</title><p>Authorization failed. Return to senpi and retry.</p>";
28
27
  // Lazily-bound 127.0.0.1 listener that exists only for the duration of one
29
28
  // OAuth authorization. The handle is unref'd so it never keeps the process
30
29
  // alive, and it tears down on success, invalid state, or a 5-minute timeout.
@@ -105,11 +104,11 @@ export class LoopbackCallbackServer {
105
104
  const state = url.searchParams.get("state") ?? undefined;
106
105
  const error = url.searchParams.get("error");
107
106
  if (error !== null || code === null || state === undefined || !this.#options.validateState(state)) {
108
- res.writeHead(400, { "content-type": "text/html" }).end(FAILURE_HTML);
107
+ res.writeHead(400, { "content-type": "text/html" }).end(oauthErrorHtml("MCP server authorization failed. Return to the app and start the sign-in again."));
109
108
  void this.#fail(new OAuthFlowError("state_mismatch", `MCP server ${this.#options.serverName} authorization state did not match (possible CSRF or a stale/replayed link); restart the auth flow.`, { serverName: this.#options.serverName }));
110
109
  return;
111
110
  }
112
- res.writeHead(200, { "content-type": "text/html" }).end(SUCCESS_HTML);
111
+ res.writeHead(200, { "content-type": "text/html" }).end(oauthSuccessHtml("MCP server authorization complete. You can close this tab."));
113
112
  this.#succeed({ code, state, iss: url.searchParams.get("iss") ?? undefined });
114
113
  }
115
114
  #succeed(result) {
@@ -1,4 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { oauthBrandName } from "@earendil-works/pi-ai/utils/oauth-page";
2
3
  import { fingerprintSecret } from "../log.js";
3
4
  import { OAuthFlowError } from "./oauth-errors.js";
4
5
  import { canonicalAuthorizationServerIssuer, credentialsMatchIssuer, validatedAuthorizationServerIssuer, } from "./oauth-issuer.js";
@@ -113,7 +114,7 @@ export class McpOAuthProvider {
113
114
  grant_types: ["authorization_code", "refresh_token"],
114
115
  response_types: ["code"],
115
116
  token_endpoint_auth_method: "none",
116
- client_name: "senpi",
117
+ client_name: oauthBrandName(),
117
118
  scope: this.#options.scopes?.join(" "),
118
119
  };
119
120
  }
@@ -43,12 +43,20 @@ export declare class ModelRegistry {
43
43
  /**
44
44
  * The live auth check reads the in-memory credentials. When the last read found the store
45
45
  * locked they were never loaded, so re-read now (bounded by the sync lock budget) instead
46
- * of answering from the empty fallback for the rest of the process.
46
+ * of answering from the empty fallback for the rest of the process. A store that could not be
47
+ * loaded is re-read the same way, so a repaired auth.json is seen without a restart (#4413).
47
48
  */
48
49
  private retryBusyCredentialRead;
49
50
  getUpstreamModelId(model: Model<Api>): string | undefined;
50
51
  getServiceTier(model: Model<Api>): "auto" | "flex" | "priority" | "ultrafast" | undefined;
51
- getApiKeyAndHeaders(model: Model<Api>): Promise<ResolvedRequestAuth>;
52
+ /**
53
+ * Request auth for a direct provider call. A caller acting for a session passes its id so the
54
+ * key belongs to the account that session's turns use; without it the stored credential is
55
+ * resolved as for session-less callers.
56
+ */
57
+ getApiKeyAndHeaders(model: Model<Api>, options?: {
58
+ sessionId?: string;
59
+ }): Promise<ResolvedRequestAuth>;
52
60
  getProviderAuthStatus(provider: string): AuthStatus;
53
61
  /** `getProviderAuthStatus` plus whether it is a non-stored sign-in, from one resolution (#3537). */
54
62
  resolveProviderAuth(provider: string): {
@@ -59,10 +59,11 @@ export class ModelRegistry {
59
59
  /**
60
60
  * The live auth check reads the in-memory credentials. When the last read found the store
61
61
  * locked they were never loaded, so re-read now (bounded by the sync lock budget) instead
62
- * of answering from the empty fallback for the rest of the process.
62
+ * of answering from the empty fallback for the rest of the process. A store that could not be
63
+ * loaded is re-read the same way, so a repaired auth.json is seen without a restart (#4413).
63
64
  */
64
65
  retryBusyCredentialRead() {
65
- if (this.authStorage.isCredentialStoreBusy())
66
+ if (this.authStorage.isCredentialStoreBusy() || this.authStorage.getLoadError())
66
67
  this.authStorage.reload();
67
68
  }
68
69
  getUpstreamModelId(model) {
@@ -71,9 +72,16 @@ export class ModelRegistry {
71
72
  getServiceTier(model) {
72
73
  return this.runtime.getCompatibilityRequestConfig(model).serviceTier;
73
74
  }
74
- async getApiKeyAndHeaders(model) {
75
+ /**
76
+ * Request auth for a direct provider call. A caller acting for a session passes its id so the
77
+ * key belongs to the account that session's turns use; without it the stored credential is
78
+ * resolved as for session-less callers.
79
+ */
80
+ async getApiKeyAndHeaders(model, options = {}) {
75
81
  try {
76
- const resolution = await this.runtime.getAuth(model);
82
+ const resolution = options.sessionId === undefined
83
+ ? await this.runtime.getAuth(model)
84
+ : await this.runtime.getSessionAuth(model, options.sessionId);
77
85
  const compatibility = this.runtime.getCompatibilityRequestConfig(model);
78
86
  if (!resolution) {
79
87
  if (compatibility.authHeader) {
@@ -168,6 +168,14 @@ export declare class ModelRuntime implements Models {
168
168
  isFallbackEligible(providerId: string): boolean;
169
169
  getAuth(providerId: string, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
170
170
  getAuth(model: AnyModel, overrides?: ModelRuntimeAuthOverrides): Promise<AuthResult | undefined>;
171
+ /**
172
+ * Auth for a request a session sends outside the rotation stream (compaction, /btw, look-at,
173
+ * cache keepalive). The key must belong to the account the session's own turns use, so a pooled
174
+ * provider resolves the slot the stream would pick for this session; without a pool, or when
175
+ * the stream would not rotate either, it is `getAuth(model)`.
176
+ */
177
+ getSessionAuth(model: Model<Api>, sessionId: string): Promise<AuthResult | undefined>;
178
+ private sessionSlotAuth;
171
179
  private enqueueCredentialOperation;
172
180
  private synchronizeCredentialState;
173
181
  setRuntimeApiKey(providerId: string, apiKey: string, options?: AuthOperationOptions): Promise<void>;
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { clampThinkingLevel, createModels, getCurrentTools, isModelType, lazyStream, ModelsError, normalizeContext, normalizeProviderId, providerNotConfiguredMessage, setWireIdentity, wrapStreamWithModelRecovery, } from "@earendil-works/pi-ai";
3
3
  import * as builtinProviderCatalog from "@earendil-works/pi-ai/providers/all";
4
+ import { setOAuthPageBrand } from "@earendil-works/pi-ai/utils/oauth-page";
4
5
  import { assertChatModel, assertClassifierModel, assertImageModel, classifierErrorResult, imageErrorResult, } from "@earendil-works/pi-ai/utils/model-operations";
5
6
  import { APP_NAME, BRAND, getAgentDir } from "../config.js";
6
7
  import { operationSignal, raceWithAbortSignal } from "../utils/abort.js";
@@ -23,6 +24,7 @@ import { createVirtualModel, findLatestResponse, isVirtualModel, withVirtualMode
23
24
  // The product's identity must ride outgoing requests. This lives here because the AI package
24
25
  // is already part of this module's graph; the CLI bootstrap deliberately does not import it.
25
26
  setWireIdentity(BRAND?.userAgent ?? APP_NAME);
27
+ setOAuthPageBrand({ name: APP_NAME, ...(BRAND?.logo ? { logo: BRAND.logo } : {}) });
26
28
  /** Availability re-reads after a busy credential store: 250, 500, 1000, 2000, 4000 ms. */
27
29
  const BUSY_AVAILABILITY_RETRY_LIMIT = 5;
28
30
  const BUSY_AVAILABILITY_RETRY_BASE_MS = 250;
@@ -550,6 +552,42 @@ export class ModelRuntime {
550
552
  },
551
553
  };
552
554
  }
555
+ /**
556
+ * Auth for a request a session sends outside the rotation stream (compaction, /btw, look-at,
557
+ * cache keepalive). The key must belong to the account the session's own turns use, so a pooled
558
+ * provider resolves the slot the stream would pick for this session; without a pool, or when
559
+ * the stream would not rotate either, it is `getAuth(model)`.
560
+ */
561
+ async getSessionAuth(model, sessionId) {
562
+ return this.getAuth(model, (await this.sessionSlotAuth(model, sessionId)) ?? {});
563
+ }
564
+ async sessionSlotAuth(model, sessionId) {
565
+ // The stream's own synchronous admission check, so a provider the stream would not rotate costs
566
+ // nothing beyond getAuth. Remote compaction's before_provider_request hook calls this on every
567
+ // request to such a model; there a pooled provider pays one extra credential read and two slot
568
+ // listings (each reads the pool's health state) before getAuth.
569
+ if (!this.couldRotateCredentials(model, undefined))
570
+ return undefined;
571
+ const sources = await this.credentialRotationSources(model, undefined);
572
+ if (!sources)
573
+ return undefined;
574
+ const { rotation } = await this.loadCredentialPool();
575
+ const { selectSessionSlot } = await import("./credential-pool/session-slot.js");
576
+ const slots = await rotation.listRotationSlots(sources, {
577
+ acquireLeases: false,
578
+ modelId: this.getCompatibilityRequestConfig(model).upstreamModelId ?? model.id,
579
+ });
580
+ const pooled = sources.credential;
581
+ const slot = selectSessionSlot(slots, {
582
+ affinityKey: sessionId,
583
+ useAffinity: sources.policy?.affinity !== false,
584
+ hasher: rotation.sha256SlotHasher,
585
+ ...(pooled?.order === undefined ? {} : { order: pooled.order }),
586
+ }, Date.now());
587
+ if (!slot)
588
+ return undefined;
589
+ return slot.lane === "env" ? { apiKey: slot.envKey } : { slotName: slot.name };
590
+ }
553
591
  enqueueCredentialOperation(providerId, signal, task) {
554
592
  const previous = this.credentialOperations.get(providerId) ?? Promise.resolve();
555
593
  let markStarted;
@@ -14,6 +14,8 @@ export interface ActiveFallbackState {
14
14
  pinnedByBilling: boolean;
15
15
  /** Derived OR of the two provenance flags - the only field consumers read. */
16
16
  pinned: boolean;
17
+ /** The original provider's login id when the episode began; a sign-in moves it and releases a billing pin (senpi#4099). */
18
+ recoveryRevision?: string;
17
19
  }
18
20
  export type FallbackReason = "transient" | "refusal" | "hard-error" | "billing";
19
21
  /** Absent: the original recovered after its cooldown. `fallback-unusable`: the fallback itself could not serve. */
@@ -64,5 +66,10 @@ export interface RetryFallbackControllerDeps {
64
66
  thinkingLevel?: ThinkingLevel;
65
67
  } | undefined;
66
68
  isAuthAvailable(provider: string): boolean;
69
+ /**
70
+ * The `loginId` the last sign-in stored for `provider` ("" when none did), never key material;
71
+ * `undefined` when it cannot be read. Only a sign-in writes a new id; a token refresh keeps it or drops it to "" (senpi#4099).
72
+ */
73
+ recoveryRevision?(provider: string): Promise<string | undefined>;
67
74
  }
68
75
  //# sourceMappingURL=controller-types.d.ts.map
@@ -41,6 +41,8 @@ export declare class RetryFallbackController {
41
41
  * original is not is never where the session rests (senpi#2376). An original that is
42
42
  * itself still held (an account usage limit with the reset it named, which can outlast
43
43
  * the circuit's 30-minute ceiling) is not restored before that hold ends (senpi#3347).
44
+ * At a turn boundary, a changed credential on the original's provider first releases
45
+ * its billing pin and parks (senpi#4099).
44
46
  */
45
47
  maybeRestorePrimary(revertPolicy: "cooldown-expiry" | "never", boundary?: "turn" | "continuation"): Promise<boolean>;
46
48
  private originalRecovered;
@@ -4,6 +4,7 @@ import { firstUsableCandidate } from "./candidates.js";
4
4
  import { baseSelector, canonicalizeFallbackChains, formatSelector, parseFallbackSelector, resolveChainKey, rungSelector, } from "./chains.js";
5
5
  import { subscribeProviderAccountEvents } from "../extensions/builtin/anthropic-subscription/account-events.js";
6
6
  import { CircuitProbes } from "./circuit-probes.js";
7
+ import { releaseOnCredentialChange } from "./credential-recovery.js";
7
8
  import { pinAfterSwitch } from "./pin.js";
8
9
  import { isPureNetworkTransient } from "./transient-network.js";
9
10
  import { UnusableEntries } from "./unusable.js";
@@ -81,6 +82,8 @@ export class RetryFallbackController {
81
82
  * original is not is never where the session rests (senpi#2376). An original that is
82
83
  * itself still held (an account usage limit with the reset it named, which can outlast
83
84
  * the circuit's 30-minute ceiling) is not restored before that hold ends (senpi#3347).
85
+ * At a turn boundary, a changed credential on the original's provider first releases
86
+ * its billing pin and parks (senpi#4099).
84
87
  */
85
88
  async maybeRestorePrimary(revertPolicy, boundary = "turn") {
86
89
  const heldForNetworkBlip = this.heldForNetworkBlip && boundary === "continuation";
@@ -88,14 +91,16 @@ export class RetryFallbackController {
88
91
  this.heldForNetworkBlip = false;
89
92
  const state = this.state;
90
93
  const current = this.deps.getCurrentSelector();
91
- if (!state || state.pinned || !current)
94
+ if (!state || !current)
92
95
  return false;
93
96
  const selector = parseFallbackSelector(state.originalSelector, this.deps.registry);
94
- if (!selector || !this.deps.isAuthAvailable(selector.provider))
95
- return false;
96
- const model = this.deps.registry.find(selector.provider, selector.id);
97
+ const model = selector && this.deps.registry.find(selector.provider, selector.id);
97
98
  if (!model)
98
99
  return false;
100
+ if (boundary === "turn")
101
+ await releaseOnCredentialChange(state, model, this.deps, this.unusable);
102
+ if (state.pinned || !this.deps.isAuthAvailable(model.provider))
103
+ return false;
99
104
  const stranded = this.unusable.isUnusable(current.model) && !this.unusable.isUnusable(model);
100
105
  if (!stranded &&
101
106
  (heldForNetworkBlip || this.unusable.isUnusable(model) || !this.originalRecovered(state, revertPolicy)))
@@ -313,7 +318,11 @@ export class RetryFallbackController {
313
318
  const from = formatSelector(current.model);
314
319
  const to = formatSelector(candidate.model);
315
320
  const prior = this.state;
321
+ const recoveryRevision = prior
322
+ ? prior.recoveryRevision
323
+ : await this.deps.recoveryRevision?.(current.model.provider);
316
324
  this.state = {
325
+ ...(recoveryRevision === undefined ? {} : { recoveryRevision }),
317
326
  chainKey: candidate.chainKey,
318
327
  originalThinkingLevel: prior?.originalThinkingLevel ?? current.thinkingLevel,
319
328
  lastAppliedThinkingLevel: thinking,
@@ -0,0 +1,18 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ActiveFallbackState, RetryFallbackControllerDeps } from "./controller-types.ts";
3
+ import type { UnusableEntries } from "./unusable.ts";
4
+ type RecoveryDeps = Pick<RetryFallbackControllerDeps, "recoveryRevision" | "cooldowns" | "circuits" | "logger">;
5
+ /**
6
+ * A billing pin assumes the original's account cannot recover by retrying it. A sign-in changes
7
+ * that account, so when the original provider's login id moved since the episode began, the
8
+ * billing pin and the original's cooldown, unusable mark and circuit are released and the
9
+ * existing revert path tries it once. A still-dead original falls back again and pins with the
10
+ * fresh id. This step reads the store at a turn boundary only while a billing pin holds; the
11
+ * other read is `applyCandidate` (controller.ts), once when an episode begins at its first
12
+ * fallback switch, to record the starting id. A token refresh keeps the id or drops it to "", and
13
+ * an id that is gone ("") is no sign-in, so neither releases. Refusal pins hold: a new credential
14
+ * does not change the context that was refused (senpi#4099).
15
+ */
16
+ export declare function releaseOnCredentialChange(state: ActiveFallbackState, original: Model<Api>, deps: RecoveryDeps, unusable: UnusableEntries): Promise<void>;
17
+ export {};
18
+ //# sourceMappingURL=credential-recovery.d.ts.map
@@ -0,0 +1,32 @@
1
+ import { formatSelector } from "./chains.js";
2
+ /**
3
+ * A billing pin assumes the original's account cannot recover by retrying it. A sign-in changes
4
+ * that account, so when the original provider's login id moved since the episode began, the
5
+ * billing pin and the original's cooldown, unusable mark and circuit are released and the
6
+ * existing revert path tries it once. A still-dead original falls back again and pins with the
7
+ * fresh id. This step reads the store at a turn boundary only while a billing pin holds; the
8
+ * other read is `applyCandidate` (controller.ts), once when an episode begins at its first
9
+ * fallback switch, to record the starting id. A token refresh keeps the id or drops it to "", and
10
+ * an id that is gone ("") is no sign-in, so neither releases. Refusal pins hold: a new credential
11
+ * does not change the context that was refused (senpi#4099).
12
+ */
13
+ export async function releaseOnCredentialChange(state, original, deps, unusable) {
14
+ if (!state.pinnedByBilling || state.recoveryRevision === undefined || !deps.recoveryRevision)
15
+ return;
16
+ const revision = await deps.recoveryRevision(original.provider);
17
+ if (!revision || revision === state.recoveryRevision)
18
+ return;
19
+ state.recoveryRevision = revision;
20
+ state.pinnedByBilling = false;
21
+ state.pinned = state.pinnedByRefusal;
22
+ const selector = formatSelector(original);
23
+ deps.cooldowns.clearModel(selector);
24
+ unusable.clear(original);
25
+ deps.circuits?.close(selector);
26
+ deps.logger.info("credential_recovery_released", {
27
+ chainKey: state.chainKey,
28
+ originalSelector: state.originalSelector,
29
+ trigger: "sign-in",
30
+ });
31
+ }
32
+ //# sourceMappingURL=credential-recovery.js.map