@code-yeongyu/senpi 2026.8.26-2 → 2026.8.27

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 (204) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +3 -3
  3. package/dist/cli/args.d.ts +3 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +14 -1
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/bash-executor.d.ts.map +1 -1
  8. package/dist/core/bash-executor.js +67 -29
  9. package/dist/core/bash-executor.js.map +1 -1
  10. package/dist/core/credential-pool/classify.d.ts +42 -0
  11. package/dist/core/credential-pool/classify.d.ts.map +1 -0
  12. package/dist/core/credential-pool/classify.js +75 -0
  13. package/dist/core/credential-pool/classify.js.map +1 -0
  14. package/dist/core/credential-pool/env-slots.d.ts +22 -0
  15. package/dist/core/credential-pool/env-slots.d.ts.map +1 -0
  16. package/dist/core/credential-pool/env-slots.js +37 -0
  17. package/dist/core/credential-pool/env-slots.js.map +1 -0
  18. package/dist/core/credential-pool/failover.d.ts +54 -0
  19. package/dist/core/credential-pool/failover.d.ts.map +1 -0
  20. package/dist/core/credential-pool/failover.js +106 -0
  21. package/dist/core/credential-pool/failover.js.map +1 -0
  22. package/dist/core/credential-pool/rotation-stream.d.ts +43 -0
  23. package/dist/core/credential-pool/rotation-stream.d.ts.map +1 -0
  24. package/dist/core/credential-pool/rotation-stream.js +96 -0
  25. package/dist/core/credential-pool/rotation-stream.js.map +1 -0
  26. package/dist/core/credential-pool/state-store.d.ts +89 -0
  27. package/dist/core/credential-pool/state-store.d.ts.map +1 -0
  28. package/dist/core/credential-pool/state-store.js +166 -0
  29. package/dist/core/credential-pool/state-store.js.map +1 -0
  30. package/dist/core/extensions/builtin/goal/continuation-recovery.d.ts +1 -0
  31. package/dist/core/extensions/builtin/goal/continuation-recovery.d.ts.map +1 -1
  32. package/dist/core/extensions/builtin/goal/continuation-recovery.js +2 -0
  33. package/dist/core/extensions/builtin/goal/continuation-recovery.js.map +1 -1
  34. package/dist/core/extensions/builtin/goal/continuation.d.ts +8 -1
  35. package/dist/core/extensions/builtin/goal/continuation.d.ts.map +1 -1
  36. package/dist/core/extensions/builtin/goal/continuation.js +15 -0
  37. package/dist/core/extensions/builtin/goal/continuation.js.map +1 -1
  38. package/dist/core/extensions/builtin/goal/direct-input-lifecycle.js +2 -2
  39. package/dist/core/extensions/builtin/goal/direct-input-lifecycle.js.map +1 -1
  40. package/dist/core/extensions/builtin/goal/format.d.ts +6 -1
  41. package/dist/core/extensions/builtin/goal/format.d.ts.map +1 -1
  42. package/dist/core/extensions/builtin/goal/format.js +6 -0
  43. package/dist/core/extensions/builtin/goal/format.js.map +1 -1
  44. package/dist/core/extensions/builtin/goal/lifecycle-helpers.d.ts.map +1 -1
  45. package/dist/core/extensions/builtin/goal/lifecycle-helpers.js +5 -2
  46. package/dist/core/extensions/builtin/goal/lifecycle-helpers.js.map +1 -1
  47. package/dist/core/extensions/builtin/goal/monitor-continuation.d.ts.map +1 -1
  48. package/dist/core/extensions/builtin/goal/monitor-continuation.js +2 -1
  49. package/dist/core/extensions/builtin/goal/monitor-continuation.js.map +1 -1
  50. package/dist/core/extensions/builtin/goal/persistence.js +2 -0
  51. package/dist/core/extensions/builtin/goal/persistence.js.map +1 -1
  52. package/dist/core/extensions/builtin/goal/renderers.d.ts +14 -0
  53. package/dist/core/extensions/builtin/goal/renderers.d.ts.map +1 -0
  54. package/dist/core/extensions/builtin/goal/renderers.js +153 -0
  55. package/dist/core/extensions/builtin/goal/renderers.js.map +1 -0
  56. package/dist/core/extensions/builtin/goal/store.d.ts +6 -2
  57. package/dist/core/extensions/builtin/goal/store.d.ts.map +1 -1
  58. package/dist/core/extensions/builtin/goal/store.js +8 -2
  59. package/dist/core/extensions/builtin/goal/store.js.map +1 -1
  60. package/dist/core/extensions/builtin/goal/tool-registration.d.ts.map +1 -1
  61. package/dist/core/extensions/builtin/goal/tool-registration.js +16 -6
  62. package/dist/core/extensions/builtin/goal/tool-registration.js.map +1 -1
  63. package/dist/core/extensions/builtin/goal/types.d.ts +1 -0
  64. package/dist/core/extensions/builtin/goal/types.d.ts.map +1 -1
  65. package/dist/core/extensions/builtin/goal/types.js.map +1 -1
  66. package/dist/core/extensions/builtin/todotools/index.js +1 -1
  67. package/dist/core/extensions/builtin/todotools/index.js.map +1 -1
  68. package/dist/core/extensions/builtin/todotools/native-todo-mirror.d.ts +1 -1
  69. package/dist/core/extensions/builtin/todotools/native-todo-mirror.d.ts.map +1 -1
  70. package/dist/core/extensions/builtin/todotools/native-todo-mirror.js +1 -1
  71. package/dist/core/extensions/builtin/todotools/native-todo-mirror.js.map +1 -1
  72. package/dist/core/model-config-schema.d.ts +85 -12
  73. package/dist/core/model-config-schema.d.ts.map +1 -1
  74. package/dist/core/model-config-schema.js +25 -0
  75. package/dist/core/model-config-schema.js.map +1 -1
  76. package/dist/core/model-runtime.d.ts +34 -1
  77. package/dist/core/model-runtime.d.ts.map +1 -1
  78. package/dist/core/model-runtime.js +98 -5
  79. package/dist/core/model-runtime.js.map +1 -1
  80. package/dist/core/tools/output-accumulator.d.ts +2 -0
  81. package/dist/core/tools/output-accumulator.d.ts.map +1 -1
  82. package/dist/core/tools/output-accumulator.js +32 -7
  83. package/dist/core/tools/output-accumulator.js.map +1 -1
  84. package/dist/index.d.ts +1 -1
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +1 -1
  87. package/dist/index.js.map +1 -1
  88. package/dist/main.d.ts.map +1 -1
  89. package/dist/main.js +26 -2
  90. package/dist/main.js.map +1 -1
  91. package/dist/modes/index.d.ts +1 -0
  92. package/dist/modes/index.d.ts.map +1 -1
  93. package/dist/modes/index.js +1 -0
  94. package/dist/modes/index.js.map +1 -1
  95. package/dist/modes/interactive/components/tool-execution-fallback.d.ts.map +1 -1
  96. package/dist/modes/interactive/components/tool-execution-fallback.js +82 -1
  97. package/dist/modes/interactive/components/tool-execution-fallback.js.map +1 -1
  98. package/dist/modes/interactive/interactive-host-runtime.d.ts +29 -0
  99. package/dist/modes/interactive/interactive-host-runtime.d.ts.map +1 -0
  100. package/dist/modes/interactive/interactive-host-runtime.js +244 -0
  101. package/dist/modes/interactive/interactive-host-runtime.js.map +1 -0
  102. package/dist/modes/rpc/connection-handler.d.ts.map +1 -1
  103. package/dist/modes/rpc/connection-handler.js +7 -1
  104. package/dist/modes/rpc/connection-handler.js.map +1 -1
  105. package/dist/modes/rpc/host-ensure.d.ts +41 -0
  106. package/dist/modes/rpc/host-ensure.d.ts.map +1 -0
  107. package/dist/modes/rpc/host-ensure.js +245 -0
  108. package/dist/modes/rpc/host-ensure.js.map +1 -0
  109. package/dist/modes/rpc/host-lifecycle.d.ts +57 -0
  110. package/dist/modes/rpc/host-lifecycle.d.ts.map +1 -0
  111. package/dist/modes/rpc/host-lifecycle.js +491 -0
  112. package/dist/modes/rpc/host-lifecycle.js.map +1 -0
  113. package/dist/modes/rpc/host-watchdog.d.ts +33 -0
  114. package/dist/modes/rpc/host-watchdog.d.ts.map +1 -0
  115. package/dist/modes/rpc/host-watchdog.js +142 -0
  116. package/dist/modes/rpc/host-watchdog.js.map +1 -0
  117. package/dist/modes/rpc/multi-session-host.d.ts +7 -1
  118. package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
  119. package/dist/modes/rpc/multi-session-host.js +206 -32
  120. package/dist/modes/rpc/multi-session-host.js.map +1 -1
  121. package/dist/modes/rpc/rpc-client.d.ts +25 -0
  122. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  123. package/dist/modes/rpc/rpc-client.js +83 -10
  124. package/dist/modes/rpc/rpc-client.js.map +1 -1
  125. package/dist/modes/rpc/rpc-mode.d.ts +1 -1
  126. package/dist/modes/rpc/rpc-mode.js +1 -1
  127. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  128. package/dist/modes/rpc/rpc-types.d.ts +2 -1
  129. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  130. package/dist/modes/rpc/rpc-types.js.map +1 -1
  131. package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
  132. package/dist/modes/rpc/session-command-router.js +12 -2
  133. package/dist/modes/rpc/session-command-router.js.map +1 -1
  134. package/dist/modes/rpc/session-event-writer.d.ts +13 -2
  135. package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
  136. package/dist/modes/rpc/session-event-writer.js +65 -25
  137. package/dist/modes/rpc/session-event-writer.js.map +1 -1
  138. package/docs/rpc.md +53 -1
  139. package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +18 -0
  140. package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
  141. package/node_modules/@code-yeongyu/senpi-codemode/src/kernels/js/worker-indirect-eval.js +711 -1
  142. package/node_modules/@code-yeongyu/senpi-codemode/src/prompt/eval-prompt.ts +10 -10
  143. package/node_modules/@code-yeongyu/senpi-codemode/src/tool/detached-eval-result.ts +9 -3
  144. package/node_modules/@code-yeongyu/senpi-codemode/src/tool/eval-tool.ts +6 -1
  145. package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
  146. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.d.ts +1 -1
  147. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.d.ts.map +1 -1
  148. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.js +46 -14
  149. package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.js.map +1 -1
  150. package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.d.ts +11 -0
  151. package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.d.ts.map +1 -1
  152. package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.js +30 -16
  153. package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.js.map +1 -1
  154. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.d.ts +16 -0
  155. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.d.ts.map +1 -0
  156. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.js +75 -0
  157. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.js.map +1 -0
  158. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.d.ts +45 -0
  159. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.d.ts.map +1 -0
  160. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.js +81 -0
  161. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.js.map +1 -0
  162. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.d.ts +31 -0
  163. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.d.ts.map +1 -0
  164. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.js +65 -0
  165. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.js.map +1 -0
  166. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.d.ts +12 -0
  167. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.d.ts.map +1 -1
  168. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.js +37 -0
  169. package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.js.map +1 -1
  170. package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.d.ts +6 -0
  171. package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.d.ts.map +1 -1
  172. package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.js +32 -6
  173. package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.js.map +1 -1
  174. package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.d.ts +6 -0
  175. package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.d.ts.map +1 -1
  176. package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.js +6 -1
  177. package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.js.map +1 -1
  178. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.d.ts +45 -0
  179. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.d.ts.map +1 -1
  180. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js +45 -0
  181. package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js.map +1 -1
  182. package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
  183. package/node_modules/@earendil-works/pi-ai/dist/providers/data/amazon-bedrock.json +1 -1
  184. package/node_modules/@earendil-works/pi-ai/dist/providers/data/baseten.json +1 -1
  185. package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
  186. package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-workers-ai.json +1 -1
  187. package/node_modules/@earendil-works/pi-ai/dist/providers/data/huggingface.json +1 -1
  188. package/node_modules/@earendil-works/pi-ai/dist/providers/data/minimax-cn.json +1 -1
  189. package/node_modules/@earendil-works/pi-ai/dist/providers/data/minimax.json +1 -1
  190. package/node_modules/@earendil-works/pi-ai/dist/providers/data/mistral.json +1 -1
  191. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
  192. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode.json +1 -1
  193. package/node_modules/@earendil-works/pi-ai/dist/providers/data/opengateway.json +1 -1
  194. package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
  195. package/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
  196. package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai-coding-cn.json +1 -1
  197. package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai.json +1 -1
  198. package/node_modules/@earendil-works/pi-ai/package.json +5 -5
  199. package/node_modules/@earendil-works/pi-pty/package.json +1 -1
  200. package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
  201. package/node_modules/@earendil-works/pi-tui/dist/tui.js +1 -1
  202. package/node_modules/@earendil-works/pi-tui/dist/tui.js.map +1 -1
  203. package/node_modules/@earendil-works/pi-tui/package.json +1 -1
  204. package/package.json +7 -7
@@ -0,0 +1,75 @@
1
+ import { DEFAULT_SLOT_BLOCK_MS, MAX_SLOT_BLOCK_MS } from "@earendil-works/pi-ai/auth/pool/failover";
2
+ import { normalizeProviderError } from "@earendil-works/pi-ai/utils/error-body";
3
+ import { getOverflowPatterns } from "@earendil-works/pi-ai/utils/overflow";
4
+ import { extract429RetryAfterMs } from "@earendil-works/pi-ai/utils/retry-hint";
5
+ export const COOLDOWN_BASE_MS = DEFAULT_SLOT_BLOCK_MS;
6
+ export const COOLDOWN_CAP_MS = MAX_SLOT_BLOCK_MS;
7
+ export const RETRY_SAME_MAX_ATTEMPTS = 2;
8
+ /**
9
+ * Per-slot exponential cooldown with the server hint as a FLOOR, never an
10
+ * override: a hint can only lengthen the wait the failure count already earned,
11
+ * and everything caps at 48 hours with the capping recorded.
12
+ */
13
+ export function rateLimitCooldown(failureCount, serverHintMs) {
14
+ const backoff = Math.min(COOLDOWN_CAP_MS, COOLDOWN_BASE_MS * 2 ** Math.max(0, failureCount));
15
+ const floored = serverHintMs === undefined ? backoff : Math.max(backoff, serverHintMs);
16
+ return { cooldownMs: Math.min(COOLDOWN_CAP_MS, floored), retryAfterWasCapped: floored > COOLDOWN_CAP_MS };
17
+ }
18
+ const INVALID_KEY_TEXT = /invalid[ _-]?(?:api[ _-]?)?key|authentication[_ ]?error|invalid x-api-key|unauthorized/i;
19
+ const ACCOUNT_SCOPED_403_TEXT = /account|credential|token|api[ _-]?key|organization|subscription/i;
20
+ const RATE_LIMIT_TEXT = /rate[ _-]?limit|too many requests|resource_exhausted/i;
21
+ const BILLING_TEXT = /billing|credits?[ _-]?(?:required|exhausted|balance)|insufficient[ _-]?(?:funds|quota|credit)|payment[ _-]?required|quota[ _-]?exhausted/i;
22
+ const OVERLOAD_TEXT = /overloaded/i;
23
+ const NETWORK_TEXT = /econnreset|econnrefused|etimedout|enotfound|socket hang up|fetch failed|network error|request timed out/i;
24
+ const FAIL_FAST_TEXT = /context[ _-]?(?:length|window)|maximum context|invalid[ _-]?model|model[ _-]?not[ _-]?found|malformed[ _-]?stream|premature[ _-]?(?:close|stream)/i;
25
+ const ABORT_TEXT = /\baborted?\b/i;
26
+ function isAbort(error, message) {
27
+ if (error instanceof Error && error.name === "AbortError")
28
+ return true;
29
+ return ABORT_TEXT.test(message);
30
+ }
31
+ function isOverflowText(text) {
32
+ return getOverflowPatterns().some((pattern) => pattern.test(text));
33
+ }
34
+ /**
35
+ * Maps a provider failure onto the credential-pool action space. Credential-
36
+ * scoped failures fail over (permanently for auth/billing, cooldown for rate
37
+ * limits); provider-scoped faults (5xx/overload/network) retry the SAME slot
38
+ * without blocking it, because blocking a healthy credential for a provider
39
+ * outage only destroys prompt-cache locality; everything else fails the
40
+ * request so the model fallback chain above keeps owning it.
41
+ */
42
+ export function classifyCredentialFailure(error, context = {}) {
43
+ const normalized = normalizeProviderError(error);
44
+ const text = normalized.messageCarriesBody ? normalized.message : `${normalized.message} ${normalized.body ?? ""}`;
45
+ const status = normalized.status;
46
+ const failureCount = context.failureCount ?? 0;
47
+ if (isAbort(error, text))
48
+ return { kind: "fail_request" };
49
+ if (status === 401 || INVALID_KEY_TEXT.test(text)) {
50
+ return { kind: "failover", block: { reason: "auth_error" } };
51
+ }
52
+ if (status === 403) {
53
+ return ACCOUNT_SCOPED_403_TEXT.test(text)
54
+ ? { kind: "failover", block: { reason: "auth_error" } }
55
+ : { kind: "fail_request" };
56
+ }
57
+ if (status === 402 || BILLING_TEXT.test(text)) {
58
+ return { kind: "failover", block: { reason: "account_disabled" } };
59
+ }
60
+ if (status === 429 || RATE_LIMIT_TEXT.test(text)) {
61
+ const hint = extract429RetryAfterMs({ status: status ?? 429, bodyText: text });
62
+ return { kind: "failover", block: { reason: "rate_limit", ...rateLimitCooldown(failureCount, hint) } };
63
+ }
64
+ if (isOverflowText(text) || status === 400 || status === 404 || FAIL_FAST_TEXT.test(text)) {
65
+ return { kind: "fail_request" };
66
+ }
67
+ if (status === 529 || OVERLOAD_TEXT.test(text) || (status !== undefined && status >= 500 && status < 600)) {
68
+ return { kind: "retry_same", maxAttempts: RETRY_SAME_MAX_ATTEMPTS };
69
+ }
70
+ if (status === 408 || NETWORK_TEXT.test(text)) {
71
+ return { kind: "retry_same", maxAttempts: RETRY_SAME_MAX_ATTEMPTS };
72
+ }
73
+ return { kind: "fail_request" };
74
+ }
75
+ //# sourceMappingURL=classify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"classify.js","sourceRoot":"","sources":["../../../src/core/credential-pool/classify.ts"],"names":[],"mappings":"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","sourcesContent":["import { DEFAULT_SLOT_BLOCK_MS, MAX_SLOT_BLOCK_MS } from \"@earendil-works/pi-ai/auth/pool/failover\";\nimport { normalizeProviderError } from \"@earendil-works/pi-ai/utils/error-body\";\nimport { getOverflowPatterns } from \"@earendil-works/pi-ai/utils/overflow\";\nimport { extract429RetryAfterMs } from \"@earendil-works/pi-ai/utils/retry-hint\";\n\nexport const COOLDOWN_BASE_MS = DEFAULT_SLOT_BLOCK_MS;\nexport const COOLDOWN_CAP_MS = MAX_SLOT_BLOCK_MS;\nexport const RETRY_SAME_MAX_ATTEMPTS = 2;\n\nexport type CredentialBlock =\n\t| { reason: \"auth_error\" }\n\t| { reason: \"account_disabled\" }\n\t| { reason: \"rate_limit\"; cooldownMs: number; retryAfterWasCapped: boolean };\n\nexport type CredentialAction =\n\t| { kind: \"failover\"; block: CredentialBlock }\n\t| { kind: \"retry_same\"; maxAttempts: typeof RETRY_SAME_MAX_ATTEMPTS }\n\t| { kind: \"fail_request\" };\n\n/**\n * Per-slot exponential cooldown with the server hint as a FLOOR, never an\n * override: a hint can only lengthen the wait the failure count already earned,\n * and everything caps at 48 hours with the capping recorded.\n */\nexport function rateLimitCooldown(\n\tfailureCount: number,\n\tserverHintMs?: number,\n): { cooldownMs: number; retryAfterWasCapped: boolean } {\n\tconst backoff = Math.min(COOLDOWN_CAP_MS, COOLDOWN_BASE_MS * 2 ** Math.max(0, failureCount));\n\tconst floored = serverHintMs === undefined ? backoff : Math.max(backoff, serverHintMs);\n\treturn { cooldownMs: Math.min(COOLDOWN_CAP_MS, floored), retryAfterWasCapped: floored > COOLDOWN_CAP_MS };\n}\n\nconst INVALID_KEY_TEXT = /invalid[ _-]?(?:api[ _-]?)?key|authentication[_ ]?error|invalid x-api-key|unauthorized/i;\nconst ACCOUNT_SCOPED_403_TEXT = /account|credential|token|api[ _-]?key|organization|subscription/i;\nconst RATE_LIMIT_TEXT = /rate[ _-]?limit|too many requests|resource_exhausted/i;\nconst BILLING_TEXT =\n\t/billing|credits?[ _-]?(?:required|exhausted|balance)|insufficient[ _-]?(?:funds|quota|credit)|payment[ _-]?required|quota[ _-]?exhausted/i;\nconst OVERLOAD_TEXT = /overloaded/i;\nconst NETWORK_TEXT =\n\t/econnreset|econnrefused|etimedout|enotfound|socket hang up|fetch failed|network error|request timed out/i;\nconst FAIL_FAST_TEXT =\n\t/context[ _-]?(?:length|window)|maximum context|invalid[ _-]?model|model[ _-]?not[ _-]?found|malformed[ _-]?stream|premature[ _-]?(?:close|stream)/i;\nconst ABORT_TEXT = /\\baborted?\\b/i;\n\nfunction isAbort(error: unknown, message: string): boolean {\n\tif (error instanceof Error && error.name === \"AbortError\") return true;\n\treturn ABORT_TEXT.test(message);\n}\n\nfunction isOverflowText(text: string): boolean {\n\treturn getOverflowPatterns().some((pattern) => pattern.test(text));\n}\n\n/**\n * Maps a provider failure onto the credential-pool action space. Credential-\n * scoped failures fail over (permanently for auth/billing, cooldown for rate\n * limits); provider-scoped faults (5xx/overload/network) retry the SAME slot\n * without blocking it, because blocking a healthy credential for a provider\n * outage only destroys prompt-cache locality; everything else fails the\n * request so the model fallback chain above keeps owning it.\n */\nexport function classifyCredentialFailure(error: unknown, context: { failureCount?: number } = {}): CredentialAction {\n\tconst normalized = normalizeProviderError(error);\n\tconst text = normalized.messageCarriesBody ? normalized.message : `${normalized.message} ${normalized.body ?? \"\"}`;\n\tconst status = normalized.status;\n\tconst failureCount = context.failureCount ?? 0;\n\n\tif (isAbort(error, text)) return { kind: \"fail_request\" };\n\tif (status === 401 || INVALID_KEY_TEXT.test(text)) {\n\t\treturn { kind: \"failover\", block: { reason: \"auth_error\" } };\n\t}\n\tif (status === 403) {\n\t\treturn ACCOUNT_SCOPED_403_TEXT.test(text)\n\t\t\t? { kind: \"failover\", block: { reason: \"auth_error\" } }\n\t\t\t: { kind: \"fail_request\" };\n\t}\n\tif (status === 402 || BILLING_TEXT.test(text)) {\n\t\treturn { kind: \"failover\", block: { reason: \"account_disabled\" } };\n\t}\n\tif (status === 429 || RATE_LIMIT_TEXT.test(text)) {\n\t\tconst hint = extract429RetryAfterMs({ status: status ?? 429, bodyText: text });\n\t\treturn { kind: \"failover\", block: { reason: \"rate_limit\", ...rateLimitCooldown(failureCount, hint) } };\n\t}\n\tif (isOverflowText(text) || status === 400 || status === 404 || FAIL_FAST_TEXT.test(text)) {\n\t\treturn { kind: \"fail_request\" };\n\t}\n\tif (status === 529 || OVERLOAD_TEXT.test(text) || (status !== undefined && status >= 500 && status < 600)) {\n\t\treturn { kind: \"retry_same\", maxAttempts: RETRY_SAME_MAX_ATTEMPTS };\n\t}\n\tif (status === 408 || NETWORK_TEXT.test(text)) {\n\t\treturn { kind: \"retry_same\", maxAttempts: RETRY_SAME_MAX_ATTEMPTS };\n\t}\n\treturn { kind: \"fail_request\" };\n}\n"]}
@@ -0,0 +1,22 @@
1
+ export declare const MAX_ENV_SLOT_INDEX = 16;
2
+ export type EnvCredentialSlot = {
3
+ name: string;
4
+ envVarName: string;
5
+ key: string;
6
+ source: "env";
7
+ };
8
+ /**
9
+ * Anthropic maps to bearer/OAuth token vars ahead of its API-key var; numbered
10
+ * slots must extend the API-key lane, so prefer the `*_API_KEY` entry when the
11
+ * canonical mapping lists several variables.
12
+ */
13
+ export declare function primaryEnvVar(providerId: string): string | undefined;
14
+ /**
15
+ * Discovers numbered env credential slots: the primary var yields slot `env`,
16
+ * and `<VAR>_2` .. `<VAR>_16` yield `env-2` .. `env-16`. Discovery is
17
+ * gap-tolerant - a configured `<VAR>_3` with no `<VAR>_2` is still found and no
18
+ * slot is invented for the gap. Stored credentials keep outranking every env
19
+ * slot because resolution consults env only when nothing is stored.
20
+ */
21
+ export declare function discoverEnvSlots(providerId: string, env: (name: string) => string | undefined): EnvCredentialSlot[];
22
+ //# sourceMappingURL=env-slots.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env-slots.d.ts","sourceRoot":"","sources":["../../../src/core/credential-pool/env-slots.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC,MAAM,MAAM,iBAAiB,GAAG;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,KAAK,CAAC;CACd,CAAC;AAEF;;;;GAIG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAIpE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAAG,iBAAiB,EAAE,CAYnH"}
@@ -0,0 +1,37 @@
1
+ import { getApiKeyEnvVars } from "@earendil-works/pi-ai";
2
+ export const MAX_ENV_SLOT_INDEX = 16;
3
+ /**
4
+ * Anthropic maps to bearer/OAuth token vars ahead of its API-key var; numbered
5
+ * slots must extend the API-key lane, so prefer the `*_API_KEY` entry when the
6
+ * canonical mapping lists several variables.
7
+ */
8
+ export function primaryEnvVar(providerId) {
9
+ const envVars = getApiKeyEnvVars(providerId);
10
+ if (!envVars || envVars.length === 0)
11
+ return undefined;
12
+ return envVars.find((name) => name.endsWith("_API_KEY")) ?? envVars[0];
13
+ }
14
+ /**
15
+ * Discovers numbered env credential slots: the primary var yields slot `env`,
16
+ * and `<VAR>_2` .. `<VAR>_16` yield `env-2` .. `env-16`. Discovery is
17
+ * gap-tolerant - a configured `<VAR>_3` with no `<VAR>_2` is still found and no
18
+ * slot is invented for the gap. Stored credentials keep outranking every env
19
+ * slot because resolution consults env only when nothing is stored.
20
+ */
21
+ export function discoverEnvSlots(providerId, env) {
22
+ const primary = primaryEnvVar(providerId);
23
+ if (!primary)
24
+ return [];
25
+ const slots = [];
26
+ const base = env(primary);
27
+ if (base)
28
+ slots.push({ name: "env", envVarName: primary, key: base, source: "env" });
29
+ for (let index = 2; index <= MAX_ENV_SLOT_INDEX; index++) {
30
+ const envVarName = `${primary}_${index}`;
31
+ const value = env(envVarName);
32
+ if (value)
33
+ slots.push({ name: `env-${index}`, envVarName, key: value, source: "env" });
34
+ }
35
+ return slots;
36
+ }
37
+ //# sourceMappingURL=env-slots.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env-slots.js","sourceRoot":"","sources":["../../../src/core/credential-pool/env-slots.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAEzD,MAAM,CAAC,MAAM,kBAAkB,GAAG,EAAE,CAAC;AASrC;;;;GAIG;AACH,MAAM,UAAU,aAAa,CAAC,UAAkB;IAC/C,MAAM,OAAO,GAAG,gBAAgB,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,OAAO,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IACvD,OAAO,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,IAAI,OAAO,CAAC,CAAC,CAAC,CAAC;AACxE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,UAAkB,EAAE,GAAyC;IAC7F,MAAM,OAAO,GAAG,aAAa,CAAC,UAAU,CAAC,CAAC;IAC1C,IAAI,CAAC,OAAO;QAAE,OAAO,EAAE,CAAC;IACxB,MAAM,KAAK,GAAwB,EAAE,CAAC;IACtC,MAAM,IAAI,GAAG,GAAG,CAAC,OAAO,CAAC,CAAC;IAC1B,IAAI,IAAI;QAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;IACrF,KAAK,IAAI,KAAK,GAAG,CAAC,EAAE,KAAK,IAAI,kBAAkB,EAAE,KAAK,EAAE,EAAE,CAAC;QAC1D,MAAM,UAAU,GAAG,GAAG,OAAO,IAAI,KAAK,EAAE,CAAC;QACzC,MAAM,KAAK,GAAG,GAAG,CAAC,UAAU,CAAC,CAAC;QAC9B,IAAI,KAAK;YAAE,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,KAAK,EAAE,EAAE,UAAU,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,KAAK,CAAC;AACd,CAAC","sourcesContent":["import { getApiKeyEnvVars } from \"@earendil-works/pi-ai\";\n\nexport const MAX_ENV_SLOT_INDEX = 16;\n\nexport type EnvCredentialSlot = {\n\tname: string;\n\tenvVarName: string;\n\tkey: string;\n\tsource: \"env\";\n};\n\n/**\n * Anthropic maps to bearer/OAuth token vars ahead of its API-key var; numbered\n * slots must extend the API-key lane, so prefer the `*_API_KEY` entry when the\n * canonical mapping lists several variables.\n */\nexport function primaryEnvVar(providerId: string): string | undefined {\n\tconst envVars = getApiKeyEnvVars(providerId);\n\tif (!envVars || envVars.length === 0) return undefined;\n\treturn envVars.find((name) => name.endsWith(\"_API_KEY\")) ?? envVars[0];\n}\n\n/**\n * Discovers numbered env credential slots: the primary var yields slot `env`,\n * and `<VAR>_2` .. `<VAR>_16` yield `env-2` .. `env-16`. Discovery is\n * gap-tolerant - a configured `<VAR>_3` with no `<VAR>_2` is still found and no\n * slot is invented for the gap. Stored credentials keep outranking every env\n * slot because resolution consults env only when nothing is stored.\n */\nexport function discoverEnvSlots(providerId: string, env: (name: string) => string | undefined): EnvCredentialSlot[] {\n\tconst primary = primaryEnvVar(providerId);\n\tif (!primary) return [];\n\tconst slots: EnvCredentialSlot[] = [];\n\tconst base = env(primary);\n\tif (base) slots.push({ name: \"env\", envVarName: primary, key: base, source: \"env\" });\n\tfor (let index = 2; index <= MAX_ENV_SLOT_INDEX; index++) {\n\t\tconst envVarName = `${primary}_${index}`;\n\t\tconst value = env(envVarName);\n\t\tif (value) slots.push({ name: `env-${index}`, envVarName, key: value, source: \"env\" });\n\t}\n\treturn slots;\n}\n"]}
@@ -0,0 +1,54 @@
1
+ import { TURN_RETRY_SUPPRESSION_PREFIX } from "@earendil-works/pi-ai/auth/pool/failover";
2
+ import { type CredentialAction, type CredentialBlock } from "./classify.ts";
3
+ export { TURN_RETRY_SUPPRESSION_PREFIX };
4
+ export type RunSlot = {
5
+ name: string;
6
+ blockedUntil?: number;
7
+ blockReason?: string;
8
+ failureCount?: number;
9
+ };
10
+ export type CredentialFailoverEvent<TSlot extends RunSlot> = {
11
+ slot: TSlot;
12
+ block: CredentialBlock;
13
+ attempt: number;
14
+ committedOutput: boolean;
15
+ };
16
+ export type RunCredentialFailoverOptions<TEvent, TSlot extends RunSlot> = {
17
+ /** Re-read before every distinct-credential attempt so a newly added slot participates. */
18
+ listSlots: () => Promise<readonly TSlot[]> | readonly TSlot[];
19
+ select: (slots: readonly TSlot[]) => TSlot;
20
+ runAttempt: (slot: TSlot) => AsyncIterable<TEvent> | Promise<AsyncIterable<TEvent>>;
21
+ /**
22
+ * REQUIRED and default-DENY by contract: return false only for event types
23
+ * explicitly known to be pre-commit bookkeeping. Any event this predicate
24
+ * does not recognize must count as committed output.
25
+ */
26
+ isCommittedOutput: (event: TEvent) => boolean;
27
+ classify?: (error: unknown, context: {
28
+ failureCount: number;
29
+ }) => CredentialAction;
30
+ errorFromEvent?: (event: TEvent) => unknown | undefined;
31
+ /** Persisted BEFORE a replacement slot is selected so a crash never forgets a block. */
32
+ persistBlock: (slot: TSlot, block: CredentialBlock) => void | Promise<void>;
33
+ onRotate?: (event: CredentialFailoverEvent<TSlot>) => void | Promise<void>;
34
+ now?: () => number;
35
+ };
36
+ export declare class CredentialFailoverError extends Error {
37
+ readonly action: CredentialAction;
38
+ readonly original: unknown;
39
+ readonly suppressTurnRetry: boolean;
40
+ readonly retryAt: number | undefined;
41
+ constructor(action: CredentialAction, original: unknown, options: {
42
+ suppressTurnRetry: boolean;
43
+ retryAt?: number;
44
+ });
45
+ }
46
+ /**
47
+ * Generic in-lane credential failover. Runs at most one failover attempt per
48
+ * slot per request, retries provider-scoped faults on the SAME slot up to the
49
+ * classifier's bound without blocking it, and rotates only while no committed
50
+ * output has reached the caller - afterwards the failure is rethrown with the
51
+ * `senpi:no-turn-retry:` marker so the turn is never replayed.
52
+ */
53
+ export declare function runCredentialFailover<TEvent, TSlot extends RunSlot>(options: RunCredentialFailoverOptions<TEvent, TSlot>): AsyncGenerator<TEvent>;
54
+ //# sourceMappingURL=failover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"failover.d.ts","sourceRoot":"","sources":["../../../src/core/credential-pool/failover.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,6BAA6B,EAAE,MAAM,0CAA0C,CAAC;AACzF,OAAO,EAAE,KAAK,gBAAgB,EAAE,KAAK,eAAe,EAA6B,MAAM,eAAe,CAAC;AAEvG,OAAO,EAAE,6BAA6B,EAAE,CAAC;AAEzC,MAAM,MAAM,OAAO,GAAG;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,uBAAuB,CAAC,KAAK,SAAS,OAAO,IAAI;IAC5D,IAAI,EAAE,KAAK,CAAC;IACZ,KAAK,EAAE,eAAe,CAAC;IACvB,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,OAAO,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,4BAA4B,CAAC,MAAM,EAAE,KAAK,SAAS,OAAO,IAAI;IACzE,2FAA2F;IAC3F,SAAS,EAAE,MAAM,OAAO,CAAC,SAAS,KAAK,EAAE,CAAC,GAAG,SAAS,KAAK,EAAE,CAAC;IAC9D,MAAM,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,EAAE,KAAK,KAAK,CAAC;IAC3C,UAAU,EAAE,CAAC,IAAI,EAAE,KAAK,KAAK,aAAa,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC,CAAC;IACpF;;;;OAIG;IACH,iBAAiB,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC;IAC9C,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE;QAAE,YAAY,EAAE,MAAM,CAAA;KAAE,KAAK,gBAAgB,CAAC;IACnF,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,GAAG,SAAS,CAAC;IACxD,wFAAwF;IACxF,YAAY,EAAE,CAAC,IAAI,EAAE,KAAK,EAAE,KAAK,EAAE,eAAe,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5E,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,uBAAuB,CAAC,KAAK,CAAC,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3E,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACnB,CAAC;AAEF,qBAAa,uBAAwB,SAAQ,KAAK;IACjD,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,iBAAiB,EAAE,OAAO,CAAC;IACpC,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;gBAEzB,MAAM,EAAE,gBAAgB,EAAE,QAAQ,EAAE,OAAO,EAAE,OAAO,EAAE;QAAE,iBAAiB,EAAE,OAAO,CAAC;QAAC,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE;CASlH;AAyBD;;;;;;GAMG;AACH,wBAAuB,qBAAqB,CAAC,MAAM,EAAE,KAAK,SAAS,OAAO,EACzE,OAAO,EAAE,4BAA4B,CAAC,MAAM,EAAE,KAAK,CAAC,GAClD,cAAc,CAAC,MAAM,CAAC,CA2DxB"}
@@ -0,0 +1,106 @@
1
+ import { TURN_RETRY_SUPPRESSION_PREFIX } from "@earendil-works/pi-ai/auth/pool/failover";
2
+ import { classifyCredentialFailure } from "./classify.js";
3
+ export { TURN_RETRY_SUPPRESSION_PREFIX };
4
+ export class CredentialFailoverError extends Error {
5
+ constructor(action, original, options) {
6
+ const detail = original instanceof Error ? original.message : String(original);
7
+ super(`${options.suppressTurnRetry ? TURN_RETRY_SUPPRESSION_PREFIX : ""}${detail}`, { cause: original });
8
+ this.name = "CredentialFailoverError";
9
+ this.action = action;
10
+ this.original = original;
11
+ this.suppressTurnRetry = options.suppressTurnRetry;
12
+ this.retryAt = options.retryAt;
13
+ }
14
+ }
15
+ function isAvailable(slot, now) {
16
+ if (slot.blockReason === "auth_error" || slot.blockReason === "account_disabled")
17
+ return false;
18
+ return slot.blockedUntil === undefined || slot.blockedUntil <= now;
19
+ }
20
+ function soonestRetryAt(slots, now) {
21
+ const deadlines = slots
22
+ .map((slot) => slot.blockedUntil)
23
+ .filter((value) => value !== undefined && value > now);
24
+ return deadlines.length === 0 ? undefined : Math.min(...deadlines);
25
+ }
26
+ async function settle(stream) {
27
+ // A failed attempt must fully release its transport before a replacement
28
+ // starts, or two live streams can interleave provider-side effects.
29
+ const iterator = stream[Symbol.asyncIterator]();
30
+ try {
31
+ await iterator.return?.(undefined);
32
+ }
33
+ catch {
34
+ // Settling a dead stream must never mask the original failure.
35
+ }
36
+ }
37
+ /**
38
+ * Generic in-lane credential failover. Runs at most one failover attempt per
39
+ * slot per request, retries provider-scoped faults on the SAME slot up to the
40
+ * classifier's bound without blocking it, and rotates only while no committed
41
+ * output has reached the caller - afterwards the failure is rethrown with the
42
+ * `senpi:no-turn-retry:` marker so the turn is never replayed.
43
+ */
44
+ export async function* runCredentialFailover(options) {
45
+ const now = options.now ?? Date.now;
46
+ const classify = options.classify ?? classifyCredentialFailure;
47
+ const attempted = new Set();
48
+ const retriesBySlot = new Map();
49
+ let lastError;
50
+ let lastOriginal;
51
+ while (true) {
52
+ const slots = await options.listSlots();
53
+ const candidates = slots.filter((slot) => !attempted.has(slot.name) && isAvailable(slot, now()));
54
+ if (candidates.length === 0) {
55
+ const retryAt = soonestRetryAt(slots, now());
56
+ throw lastError !== undefined && lastOriginal !== undefined
57
+ ? new CredentialFailoverError(lastError.action, lastOriginal, {
58
+ suppressTurnRetry: lastError.suppressTurnRetry,
59
+ ...(retryAt === undefined ? {} : { retryAt }),
60
+ })
61
+ : new CredentialFailoverError({ kind: "fail_request" }, new Error("No credential slots available"), {
62
+ suppressTurnRetry: false,
63
+ ...(retryAt === undefined ? {} : { retryAt }),
64
+ });
65
+ }
66
+ const slot = options.select(candidates);
67
+ let committedOutput = false;
68
+ let attemptStream;
69
+ try {
70
+ attemptStream = await options.runAttempt(slot);
71
+ for await (const event of attemptStream) {
72
+ const failure = options.errorFromEvent?.(event);
73
+ if (failure !== undefined)
74
+ throw failure;
75
+ committedOutput ||= options.isCommittedOutput(event);
76
+ yield event;
77
+ }
78
+ return;
79
+ }
80
+ catch (error) {
81
+ if (attemptStream)
82
+ await settle(attemptStream);
83
+ const failureCount = (slot.failureCount ?? 0) + (retriesBySlot.get(slot.name) ?? 0);
84
+ const action = classify(error, { failureCount });
85
+ lastOriginal = error;
86
+ if (action.kind === "retry_same" && !committedOutput) {
87
+ const used = (retriesBySlot.get(slot.name) ?? 0) + 1;
88
+ retriesBySlot.set(slot.name, used);
89
+ if (used < action.maxAttempts)
90
+ continue;
91
+ throw new CredentialFailoverError(action, error, { suppressTurnRetry: false });
92
+ }
93
+ if (action.kind !== "failover") {
94
+ throw new CredentialFailoverError(action, error, { suppressTurnRetry: committedOutput });
95
+ }
96
+ await options.persistBlock(slot, action.block);
97
+ attempted.add(slot.name);
98
+ const failure = new CredentialFailoverError(action, error, { suppressTurnRetry: committedOutput });
99
+ lastError = failure;
100
+ await options.onRotate?.({ slot, block: action.block, attempt: attempted.size, committedOutput });
101
+ if (committedOutput)
102
+ throw failure;
103
+ }
104
+ }
105
+ }
106
+ //# sourceMappingURL=failover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"failover.js","sourceRoot":"","sources":["../../../src/core/credential-pool/failover.ts"],"names":[],"mappings":"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","sourcesContent":["import { TURN_RETRY_SUPPRESSION_PREFIX } from \"@earendil-works/pi-ai/auth/pool/failover\";\nimport { type CredentialAction, type CredentialBlock, classifyCredentialFailure } from \"./classify.ts\";\n\nexport { TURN_RETRY_SUPPRESSION_PREFIX };\n\nexport type RunSlot = {\n\tname: string;\n\tblockedUntil?: number;\n\tblockReason?: string;\n\tfailureCount?: number;\n};\n\nexport type CredentialFailoverEvent<TSlot extends RunSlot> = {\n\tslot: TSlot;\n\tblock: CredentialBlock;\n\tattempt: number;\n\tcommittedOutput: boolean;\n};\n\nexport type RunCredentialFailoverOptions<TEvent, TSlot extends RunSlot> = {\n\t/** Re-read before every distinct-credential attempt so a newly added slot participates. */\n\tlistSlots: () => Promise<readonly TSlot[]> | readonly TSlot[];\n\tselect: (slots: readonly TSlot[]) => TSlot;\n\trunAttempt: (slot: TSlot) => AsyncIterable<TEvent> | Promise<AsyncIterable<TEvent>>;\n\t/**\n\t * REQUIRED and default-DENY by contract: return false only for event types\n\t * explicitly known to be pre-commit bookkeeping. Any event this predicate\n\t * does not recognize must count as committed output.\n\t */\n\tisCommittedOutput: (event: TEvent) => boolean;\n\tclassify?: (error: unknown, context: { failureCount: number }) => CredentialAction;\n\terrorFromEvent?: (event: TEvent) => unknown | undefined;\n\t/** Persisted BEFORE a replacement slot is selected so a crash never forgets a block. */\n\tpersistBlock: (slot: TSlot, block: CredentialBlock) => void | Promise<void>;\n\tonRotate?: (event: CredentialFailoverEvent<TSlot>) => void | Promise<void>;\n\tnow?: () => number;\n};\n\nexport class CredentialFailoverError extends Error {\n\treadonly action: CredentialAction;\n\treadonly original: unknown;\n\treadonly suppressTurnRetry: boolean;\n\treadonly retryAt: number | undefined;\n\n\tconstructor(action: CredentialAction, original: unknown, options: { suppressTurnRetry: boolean; retryAt?: number }) {\n\t\tconst detail = original instanceof Error ? original.message : String(original);\n\t\tsuper(`${options.suppressTurnRetry ? TURN_RETRY_SUPPRESSION_PREFIX : \"\"}${detail}`, { cause: original });\n\t\tthis.name = \"CredentialFailoverError\";\n\t\tthis.action = action;\n\t\tthis.original = original;\n\t\tthis.suppressTurnRetry = options.suppressTurnRetry;\n\t\tthis.retryAt = options.retryAt;\n\t}\n}\n\nfunction isAvailable(slot: RunSlot, now: number): boolean {\n\tif (slot.blockReason === \"auth_error\" || slot.blockReason === \"account_disabled\") return false;\n\treturn slot.blockedUntil === undefined || slot.blockedUntil <= now;\n}\n\nfunction soonestRetryAt(slots: readonly RunSlot[], now: number): number | undefined {\n\tconst deadlines = slots\n\t\t.map((slot) => slot.blockedUntil)\n\t\t.filter((value): value is number => value !== undefined && value > now);\n\treturn deadlines.length === 0 ? undefined : Math.min(...deadlines);\n}\n\nasync function settle<TEvent>(stream: AsyncIterable<TEvent>): Promise<void> {\n\t// A failed attempt must fully release its transport before a replacement\n\t// starts, or two live streams can interleave provider-side effects.\n\tconst iterator = stream[Symbol.asyncIterator]();\n\ttry {\n\t\tawait iterator.return?.(undefined);\n\t} catch {\n\t\t// Settling a dead stream must never mask the original failure.\n\t}\n}\n\n/**\n * Generic in-lane credential failover. Runs at most one failover attempt per\n * slot per request, retries provider-scoped faults on the SAME slot up to the\n * classifier's bound without blocking it, and rotates only while no committed\n * output has reached the caller - afterwards the failure is rethrown with the\n * `senpi:no-turn-retry:` marker so the turn is never replayed.\n */\nexport async function* runCredentialFailover<TEvent, TSlot extends RunSlot>(\n\toptions: RunCredentialFailoverOptions<TEvent, TSlot>,\n): AsyncGenerator<TEvent> {\n\tconst now = options.now ?? Date.now;\n\tconst classify = options.classify ?? classifyCredentialFailure;\n\tconst attempted = new Set<string>();\n\tconst retriesBySlot = new Map<string, number>();\n\tlet lastError: CredentialFailoverError | undefined;\n\tlet lastOriginal: unknown;\n\n\twhile (true) {\n\t\tconst slots = await options.listSlots();\n\t\tconst candidates = slots.filter((slot) => !attempted.has(slot.name) && isAvailable(slot, now()));\n\t\tif (candidates.length === 0) {\n\t\t\tconst retryAt = soonestRetryAt(slots, now());\n\t\t\tthrow lastError !== undefined && lastOriginal !== undefined\n\t\t\t\t? new CredentialFailoverError(lastError.action, lastOriginal, {\n\t\t\t\t\t\tsuppressTurnRetry: lastError.suppressTurnRetry,\n\t\t\t\t\t\t...(retryAt === undefined ? {} : { retryAt }),\n\t\t\t\t\t})\n\t\t\t\t: new CredentialFailoverError({ kind: \"fail_request\" }, new Error(\"No credential slots available\"), {\n\t\t\t\t\t\tsuppressTurnRetry: false,\n\t\t\t\t\t\t...(retryAt === undefined ? {} : { retryAt }),\n\t\t\t\t\t});\n\t\t}\n\t\tconst slot = options.select(candidates);\n\t\tlet committedOutput = false;\n\t\tlet attemptStream: AsyncIterable<TEvent> | undefined;\n\t\ttry {\n\t\t\tattemptStream = await options.runAttempt(slot);\n\t\t\tfor await (const event of attemptStream) {\n\t\t\t\tconst failure = options.errorFromEvent?.(event);\n\t\t\t\tif (failure !== undefined) throw failure;\n\t\t\t\tcommittedOutput ||= options.isCommittedOutput(event);\n\t\t\t\tyield event;\n\t\t\t}\n\t\t\treturn;\n\t\t} catch (error) {\n\t\t\tif (attemptStream) await settle(attemptStream);\n\t\t\tconst failureCount = (slot.failureCount ?? 0) + (retriesBySlot.get(slot.name) ?? 0);\n\t\t\tconst action = classify(error, { failureCount });\n\t\t\tlastOriginal = error;\n\n\t\t\tif (action.kind === \"retry_same\" && !committedOutput) {\n\t\t\t\tconst used = (retriesBySlot.get(slot.name) ?? 0) + 1;\n\t\t\t\tretriesBySlot.set(slot.name, used);\n\t\t\t\tif (used < action.maxAttempts) continue;\n\t\t\t\tthrow new CredentialFailoverError(action, error, { suppressTurnRetry: false });\n\t\t\t}\n\t\t\tif (action.kind !== \"failover\") {\n\t\t\t\tthrow new CredentialFailoverError(action, error, { suppressTurnRetry: committedOutput });\n\t\t\t}\n\n\t\t\tawait options.persistBlock(slot, action.block);\n\t\t\tattempted.add(slot.name);\n\t\t\tconst failure = new CredentialFailoverError(action, error, { suppressTurnRetry: committedOutput });\n\t\t\tlastError = failure;\n\t\t\tawait options.onRotate?.({ slot, block: action.block, attempt: attempted.size, committedOutput });\n\t\t\tif (committedOutput) throw failure;\n\t\t}\n\t}\n}\n"]}
@@ -0,0 +1,43 @@
1
+ import type { AssistantMessageEvent, Credential } from "@earendil-works/pi-ai";
2
+ import { type SlotHasher } from "@earendil-works/pi-ai/auth/pool/select";
3
+ import { type RunSlot } from "./failover.ts";
4
+ import type { CredentialSlotRepository } from "./state-store.ts";
5
+ /** The exact hash the claude-sdk-oauth affinity oracle uses, so pools never remap. */
6
+ export declare const sha256SlotHasher: SlotHasher;
7
+ export type RotationLane = "stored" | "env";
8
+ export type RotationSlot = RunSlot & {
9
+ lane: RotationLane;
10
+ /** Env-lane key material for the attempt; never serialized or persisted. */
11
+ envKey?: string;
12
+ envVarName?: string;
13
+ };
14
+ export type RotationSources = {
15
+ providerId: string;
16
+ credential: Credential | undefined;
17
+ env: (name: string) => string | undefined;
18
+ repository: CredentialSlotRepository;
19
+ now?: () => number;
20
+ };
21
+ /**
22
+ * Lists the provider's rotation slots with sidecar health overlaid. Stored
23
+ * credentials own the lane when present; env slots participate only when
24
+ * nothing is stored, preserving today's resolution precedence. An env slot's
25
+ * persisted health applies only while its HMAC revision still matches the
26
+ * current env value, so rotating a key in place clears its own stale block.
27
+ */
28
+ export declare function listRotationSlots(sources: RotationSources): Promise<RotationSlot[]>;
29
+ export type CredentialRotationOptions = {
30
+ sources: RotationSources;
31
+ /** Stable session key keeps a session on its slot; absent, each request distributes. */
32
+ affinityKey?: string;
33
+ hasher?: SlotHasher;
34
+ runAttempt: (slot: RotationSlot) => AsyncIterable<AssistantMessageEvent> | Promise<AsyncIterable<AssistantMessageEvent>>;
35
+ };
36
+ /**
37
+ * In-lane credential rotation for one provider request. Selection follows the
38
+ * HRW order for the affinity key; only the `start` bookkeeping event counts as
39
+ * pre-commit, so any delta bars silent rotation (default-DENY) and failures
40
+ * after output carry the turn-retry suppression marker.
41
+ */
42
+ export declare function streamWithCredentialRotation(options: CredentialRotationOptions): AsyncGenerator<AssistantMessageEvent>;
43
+ //# sourceMappingURL=rotation-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotation-stream.d.ts","sourceRoot":"","sources":["../../../src/core/credential-pool/rotation-stream.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,qBAAqB,EAAE,UAAU,EAAE,MAAM,uBAAuB,CAAC;AAC/E,OAAO,EAAmB,KAAK,UAAU,EAAE,MAAM,wCAAwC,CAAC;AAI1F,OAAO,EAAE,KAAK,OAAO,EAAyB,MAAM,eAAe,CAAC;AACpE,OAAO,KAAK,EAAE,wBAAwB,EAAuB,MAAM,kBAAkB,CAAC;AAEtF,sFAAsF;AACtF,eAAO,MAAM,gBAAgB,EAAE,UAAsF,CAAC;AAEtH,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,KAAK,CAAC;AAE5C,MAAM,MAAM,YAAY,GAAG,OAAO,GAAG;IACpC,IAAI,EAAE,YAAY,CAAC;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,UAAU,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,UAAU,GAAG,SAAS,CAAC;IACnC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,CAAC;IAC1C,UAAU,EAAE,wBAAwB,CAAC;IACrC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACnB,CAAC;AAYF;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,eAAe,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC,CAqBzF;AAoBD,MAAM,MAAM,yBAAyB,GAAG;IACvC,OAAO,EAAE,eAAe,CAAC;IACzB,wFAAwF;IACxF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,UAAU,EAAE,CACX,IAAI,EAAE,YAAY,KACd,aAAa,CAAC,qBAAqB,CAAC,GAAG,OAAO,CAAC,aAAa,CAAC,qBAAqB,CAAC,CAAC,CAAC;CAC1F,CAAC;AAQF;;;;;GAKG;AACH,wBAAgB,4BAA4B,CAC3C,OAAO,EAAE,yBAAyB,GAChC,cAAc,CAAC,qBAAqB,CAAC,CA6BvC"}
@@ -0,0 +1,96 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { rendezvousOrder } from "@earendil-works/pi-ai/auth/pool/select";
3
+ import { listSlots as listCredentialSlots } from "@earendil-works/pi-ai/auth/pool/slots";
4
+ import { classifyCredentialFailure } from "./classify.js";
5
+ import { discoverEnvSlots } from "./env-slots.js";
6
+ import { runCredentialFailover } from "./failover.js";
7
+ /** The exact hash the claude-sdk-oauth affinity oracle uses, so pools never remap. */
8
+ export const sha256SlotHasher = (input) => createHash("sha256").update(input).digest().readBigUInt64BE(0);
9
+ function overlayState(slot, state) {
10
+ if (!state)
11
+ return slot;
12
+ return {
13
+ ...slot,
14
+ ...(state.blockedUntil === undefined ? {} : { blockedUntil: state.blockedUntil }),
15
+ ...(state.blockReason === undefined ? {} : { blockReason: state.blockReason }),
16
+ ...(state.failureCount === undefined ? {} : { failureCount: state.failureCount }),
17
+ };
18
+ }
19
+ /**
20
+ * Lists the provider's rotation slots with sidecar health overlaid. Stored
21
+ * credentials own the lane when present; env slots participate only when
22
+ * nothing is stored, preserving today's resolution precedence. An env slot's
23
+ * persisted health applies only while its HMAC revision still matches the
24
+ * current env value, so rotating a key in place clears its own stale block.
25
+ */
26
+ export async function listRotationSlots(sources) {
27
+ const { providerId, credential, env, repository } = sources;
28
+ if (credential) {
29
+ const state = await repository.listSlots(providerId, "stored");
30
+ return listCredentialSlots(credential).map((slot) => overlayState({ name: slot.name, lane: "stored" }, state[slot.name]));
31
+ }
32
+ const envSlots = discoverEnvSlots(providerId, env);
33
+ if (envSlots.length === 0)
34
+ return [];
35
+ const state = await repository.listSlots(providerId, "env");
36
+ const slots = [];
37
+ for (const slot of envSlots) {
38
+ const persisted = state[slot.name];
39
+ const revision = await repository.envCredentialRevision(slot.envVarName, slot.key);
40
+ const applicable = persisted?.credentialRevision === revision ? persisted : undefined;
41
+ slots.push(overlayState({ name: slot.name, lane: "env", envKey: slot.key, envVarName: slot.envVarName }, applicable));
42
+ }
43
+ return slots;
44
+ }
45
+ function blockPatch(block, current, now, credentialRevision) {
46
+ const failureCount = (current?.failureCount ?? 0) + 1;
47
+ const base = {
48
+ failureCount,
49
+ ...(credentialRevision === undefined ? {} : { credentialRevision }),
50
+ ...(current?.lastSuccessAt === undefined ? {} : { lastSuccessAt: current.lastSuccessAt }),
51
+ };
52
+ if (block.reason === "rate_limit") {
53
+ return { ...base, blockedUntil: now + block.cooldownMs, blockReason: "rate_limit" };
54
+ }
55
+ return { ...base, blockReason: block.reason };
56
+ }
57
+ function errorFromEvent(event) {
58
+ if (event.type !== "error")
59
+ return undefined;
60
+ const message = event.error.errorMessage ?? "provider stream error";
61
+ return new Error(message);
62
+ }
63
+ /**
64
+ * In-lane credential rotation for one provider request. Selection follows the
65
+ * HRW order for the affinity key; only the `start` bookkeeping event counts as
66
+ * pre-commit, so any delta bars silent rotation (default-DENY) and failures
67
+ * after output carry the turn-retry suppression marker.
68
+ */
69
+ export function streamWithCredentialRotation(options) {
70
+ const { sources, runAttempt } = options;
71
+ const hasher = options.hasher ?? sha256SlotHasher;
72
+ const affinityKey = options.affinityKey ?? randomUUID();
73
+ const now = sources.now ?? Date.now;
74
+ return runCredentialFailover({
75
+ listSlots: () => listRotationSlots(sources),
76
+ select: (candidates) => {
77
+ const ordered = rendezvousOrder(affinityKey, candidates, hasher);
78
+ const winner = ordered[0];
79
+ if (!winner)
80
+ throw new Error("credential rotation selected from an empty candidate set");
81
+ return winner;
82
+ },
83
+ runAttempt,
84
+ isCommittedOutput: (event) => event.type !== "start",
85
+ errorFromEvent,
86
+ classify: classifyCredentialFailure,
87
+ persistBlock: async (slot, block) => {
88
+ const revision = slot.lane === "env" && slot.envVarName !== undefined && slot.envKey !== undefined
89
+ ? await sources.repository.envCredentialRevision(slot.envVarName, slot.envKey)
90
+ : undefined;
91
+ await sources.repository.mutateSlotState(sources.providerId, slot.lane, slot.name, (current) => blockPatch(block, current, now(), revision));
92
+ },
93
+ now,
94
+ });
95
+ }
96
+ //# sourceMappingURL=rotation-stream.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rotation-stream.js","sourceRoot":"","sources":["../../../src/core/credential-pool/rotation-stream.ts"],"names":[],"mappings":"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","sourcesContent":["import { createHash, randomUUID } from \"node:crypto\";\nimport type { AssistantMessageEvent, Credential } from \"@earendil-works/pi-ai\";\nimport { rendezvousOrder, type SlotHasher } from \"@earendil-works/pi-ai/auth/pool/select\";\nimport { listSlots as listCredentialSlots } from \"@earendil-works/pi-ai/auth/pool/slots\";\nimport { type CredentialBlock, classifyCredentialFailure } from \"./classify.ts\";\nimport { discoverEnvSlots } from \"./env-slots.ts\";\nimport { type RunSlot, runCredentialFailover } from \"./failover.ts\";\nimport type { CredentialSlotRepository, CredentialSlotState } from \"./state-store.ts\";\n\n/** The exact hash the claude-sdk-oauth affinity oracle uses, so pools never remap. */\nexport const sha256SlotHasher: SlotHasher = (input) => createHash(\"sha256\").update(input).digest().readBigUInt64BE(0);\n\nexport type RotationLane = \"stored\" | \"env\";\n\nexport type RotationSlot = RunSlot & {\n\tlane: RotationLane;\n\t/** Env-lane key material for the attempt; never serialized or persisted. */\n\tenvKey?: string;\n\tenvVarName?: string;\n};\n\nexport type RotationSources = {\n\tproviderId: string;\n\tcredential: Credential | undefined;\n\tenv: (name: string) => string | undefined;\n\trepository: CredentialSlotRepository;\n\tnow?: () => number;\n};\n\nfunction overlayState(slot: RotationSlot, state: CredentialSlotState | undefined): RotationSlot {\n\tif (!state) return slot;\n\treturn {\n\t\t...slot,\n\t\t...(state.blockedUntil === undefined ? {} : { blockedUntil: state.blockedUntil }),\n\t\t...(state.blockReason === undefined ? {} : { blockReason: state.blockReason }),\n\t\t...(state.failureCount === undefined ? {} : { failureCount: state.failureCount }),\n\t};\n}\n\n/**\n * Lists the provider's rotation slots with sidecar health overlaid. Stored\n * credentials own the lane when present; env slots participate only when\n * nothing is stored, preserving today's resolution precedence. An env slot's\n * persisted health applies only while its HMAC revision still matches the\n * current env value, so rotating a key in place clears its own stale block.\n */\nexport async function listRotationSlots(sources: RotationSources): Promise<RotationSlot[]> {\n\tconst { providerId, credential, env, repository } = sources;\n\tif (credential) {\n\t\tconst state = await repository.listSlots(providerId, \"stored\");\n\t\treturn listCredentialSlots(credential).map((slot) =>\n\t\t\toverlayState({ name: slot.name, lane: \"stored\" }, state[slot.name]),\n\t\t);\n\t}\n\tconst envSlots = discoverEnvSlots(providerId, env);\n\tif (envSlots.length === 0) return [];\n\tconst state = await repository.listSlots(providerId, \"env\");\n\tconst slots: RotationSlot[] = [];\n\tfor (const slot of envSlots) {\n\t\tconst persisted = state[slot.name];\n\t\tconst revision = await repository.envCredentialRevision(slot.envVarName, slot.key);\n\t\tconst applicable = persisted?.credentialRevision === revision ? persisted : undefined;\n\t\tslots.push(\n\t\t\toverlayState({ name: slot.name, lane: \"env\", envKey: slot.key, envVarName: slot.envVarName }, applicable),\n\t\t);\n\t}\n\treturn slots;\n}\n\nfunction blockPatch(\n\tblock: CredentialBlock,\n\tcurrent: CredentialSlotState | undefined,\n\tnow: number,\n\tcredentialRevision?: string,\n): Omit<CredentialSlotState, \"stateVersion\"> {\n\tconst failureCount = (current?.failureCount ?? 0) + 1;\n\tconst base = {\n\t\tfailureCount,\n\t\t...(credentialRevision === undefined ? {} : { credentialRevision }),\n\t\t...(current?.lastSuccessAt === undefined ? {} : { lastSuccessAt: current.lastSuccessAt }),\n\t};\n\tif (block.reason === \"rate_limit\") {\n\t\treturn { ...base, blockedUntil: now + block.cooldownMs, blockReason: \"rate_limit\" };\n\t}\n\treturn { ...base, blockReason: block.reason };\n}\n\nexport type CredentialRotationOptions = {\n\tsources: RotationSources;\n\t/** Stable session key keeps a session on its slot; absent, each request distributes. */\n\taffinityKey?: string;\n\thasher?: SlotHasher;\n\trunAttempt: (\n\t\tslot: RotationSlot,\n\t) => AsyncIterable<AssistantMessageEvent> | Promise<AsyncIterable<AssistantMessageEvent>>;\n};\n\nfunction errorFromEvent(event: AssistantMessageEvent): unknown {\n\tif (event.type !== \"error\") return undefined;\n\tconst message = event.error.errorMessage ?? \"provider stream error\";\n\treturn new Error(message);\n}\n\n/**\n * In-lane credential rotation for one provider request. Selection follows the\n * HRW order for the affinity key; only the `start` bookkeeping event counts as\n * pre-commit, so any delta bars silent rotation (default-DENY) and failures\n * after output carry the turn-retry suppression marker.\n */\nexport function streamWithCredentialRotation(\n\toptions: CredentialRotationOptions,\n): AsyncGenerator<AssistantMessageEvent> {\n\tconst { sources, runAttempt } = options;\n\tconst hasher = options.hasher ?? sha256SlotHasher;\n\tconst affinityKey = options.affinityKey ?? randomUUID();\n\tconst now = sources.now ?? Date.now;\n\n\treturn runCredentialFailover<AssistantMessageEvent, RotationSlot>({\n\t\tlistSlots: () => listRotationSlots(sources),\n\t\tselect: (candidates) => {\n\t\t\tconst ordered = rendezvousOrder(affinityKey, candidates, hasher);\n\t\t\tconst winner = ordered[0];\n\t\t\tif (!winner) throw new Error(\"credential rotation selected from an empty candidate set\");\n\t\t\treturn winner;\n\t\t},\n\t\trunAttempt,\n\t\tisCommittedOutput: (event) => event.type !== \"start\",\n\t\terrorFromEvent,\n\t\tclassify: classifyCredentialFailure,\n\t\tpersistBlock: async (slot, block) => {\n\t\t\tconst revision =\n\t\t\t\tslot.lane === \"env\" && slot.envVarName !== undefined && slot.envKey !== undefined\n\t\t\t\t\t? await sources.repository.envCredentialRevision(slot.envVarName, slot.envKey)\n\t\t\t\t\t: undefined;\n\t\t\tawait sources.repository.mutateSlotState(sources.providerId, slot.lane, slot.name, (current) =>\n\t\t\t\tblockPatch(block, current, now(), revision),\n\t\t\t);\n\t\t},\n\t\tnow,\n\t});\n}\n"]}
@@ -0,0 +1,89 @@
1
+ import { z } from "zod";
2
+ export declare const CREDENTIAL_POOL_STATE_FILENAME = "credential-pool-state.json";
3
+ declare const slotStateSchema: z.ZodObject<{
4
+ stateVersion: z.ZodNumber;
5
+ blockedUntil: z.ZodOptional<z.ZodNumber>;
6
+ blockReason: z.ZodOptional<z.ZodEnum<{
7
+ auth_error: "auth_error";
8
+ account_disabled: "account_disabled";
9
+ rate_limit: "rate_limit";
10
+ }>>;
11
+ failureCount: z.ZodOptional<z.ZodNumber>;
12
+ lastSuccessAt: z.ZodOptional<z.ZodNumber>;
13
+ credentialRevision: z.ZodOptional<z.ZodString>;
14
+ lease: z.ZodOptional<z.ZodObject<{
15
+ id: z.ZodString;
16
+ expiresAt: z.ZodNumber;
17
+ }, z.core.$strict>>;
18
+ }, z.core.$strict>;
19
+ export type CredentialSlotState = Readonly<z.infer<typeof slotStateSchema>>;
20
+ declare const documentSchema: z.ZodObject<{
21
+ schemaVersion: z.ZodLiteral<1>;
22
+ installationKey: z.ZodString;
23
+ providers: z.ZodRecord<z.ZodString, z.ZodObject<{
24
+ lanes: z.ZodRecord<z.ZodString, z.ZodObject<{
25
+ slots: z.ZodRecord<z.ZodString, z.ZodObject<{
26
+ stateVersion: z.ZodNumber;
27
+ blockedUntil: z.ZodOptional<z.ZodNumber>;
28
+ blockReason: z.ZodOptional<z.ZodEnum<{
29
+ auth_error: "auth_error";
30
+ account_disabled: "account_disabled";
31
+ rate_limit: "rate_limit";
32
+ }>>;
33
+ failureCount: z.ZodOptional<z.ZodNumber>;
34
+ lastSuccessAt: z.ZodOptional<z.ZodNumber>;
35
+ credentialRevision: z.ZodOptional<z.ZodString>;
36
+ lease: z.ZodOptional<z.ZodObject<{
37
+ id: z.ZodString;
38
+ expiresAt: z.ZodNumber;
39
+ }, z.core.$strict>>;
40
+ }, z.core.$strict>>;
41
+ }, z.core.$strict>>;
42
+ }, z.core.$strict>>;
43
+ }, z.core.$strict>;
44
+ export type CredentialPoolStateDocument = z.infer<typeof documentSchema>;
45
+ export declare function credentialPoolStatePath(agentDir?: string): string;
46
+ export type SlotHealth = "ready" | "blocked" | "half_open";
47
+ /** Deadlines are absolute so a restart never re-enables a cooling credential early. */
48
+ export declare function slotHealth(state: CredentialSlotState | undefined, now: number): SlotHealth;
49
+ /**
50
+ * File-locked health sidecar for credential pools. Never stores credential
51
+ * material: env slots are keyed by an installation-local HMAC revision so a
52
+ * rotated env value clears its own stale block without persisting a raw key
53
+ * hash anywhere.
54
+ */
55
+ export declare class CredentialSlotRepository {
56
+ private readonly path;
57
+ constructor(path?: string);
58
+ private ensureFile;
59
+ private withDocument;
60
+ installationKey(): Promise<string>;
61
+ /**
62
+ * Derives the persistence key for an env slot's health. HMAC over the
63
+ * installation key means the revision is useless outside this installation
64
+ * and never equals a raw hash of the key material.
65
+ */
66
+ envCredentialRevision(envVarName: string, envValue: string): Promise<string>;
67
+ listSlots(providerId: string, laneId: string): Promise<Record<string, CredentialSlotState>>;
68
+ /**
69
+ * Atomic read-modify-write for one slot's health. `stateVersion` increments
70
+ * on every write so concurrent mutators can detect lost updates; returning
71
+ * undefined removes the slot state entirely.
72
+ */
73
+ mutateSlotState(providerId: string, laneId: string, slotId: string, fn: (current: CredentialSlotState | undefined) => Omit<CredentialSlotState, "stateVersion"> | undefined): Promise<CredentialSlotState | undefined>;
74
+ }
75
+ export type HalfOpenLease = {
76
+ leaseId: string;
77
+ expiresAt: number;
78
+ };
79
+ /**
80
+ * Transitions an expired cooldown atomically to half_open: exactly one caller
81
+ * wins the probe lease; everyone else keeps treating the slot as unavailable
82
+ * until the lease expires or the probe settles the block.
83
+ */
84
+ export declare function acquireHalfOpenLease(repository: CredentialSlotRepository, providerId: string, laneId: string, slotId: string, options?: {
85
+ now?: number;
86
+ leaseTtlMs?: number;
87
+ }): Promise<HalfOpenLease | undefined>;
88
+ export {};
89
+ //# sourceMappingURL=state-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state-store.d.ts","sourceRoot":"","sources":["../../../src/core/credential-pool/state-store.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB,eAAO,MAAM,8BAA8B,+BAA+B,CAAC;AAS3E,QAAA,MAAM,eAAe;;;;;;;;;;;;;;;kBAQnB,CAAC;AAEH,MAAM,MAAM,mBAAmB,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAC,CAAC;AAE5E,QAAA,MAAM,cAAc;;;;;;;;;;;;;;;;;;;;;;;kBAclB,CAAC;AAEH,MAAM,MAAM,2BAA2B,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEzE,wBAAgB,uBAAuB,CAAC,QAAQ,GAAE,MAAsB,GAAG,MAAM,CAEhF;AAsBD,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,SAAS,GAAG,WAAW,CAAC;AAE3D,uFAAuF;AACvF,wBAAgB,UAAU,CAAC,KAAK,EAAE,mBAAmB,GAAG,SAAS,EAAE,GAAG,EAAE,MAAM,GAAG,UAAU,CAS1F;AAED;;;;;GAKG;AACH,qBAAa,wBAAwB;IACpC,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;gBAElB,IAAI,GAAE,MAAkC;IAIpD,OAAO,CAAC,UAAU;YAMJ,YAAY;IAepB,eAAe,IAAI,OAAO,CAAC,MAAM,CAAC;IAIxC;;;;OAIG;IACG,qBAAqB,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAK5E,SAAS,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;IAMjG;;;;OAIG;IACG,eAAe,CACpB,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,EAAE,EAAE,CAAC,OAAO,EAAE,mBAAmB,GAAG,SAAS,KAAK,IAAI,CAAC,mBAAmB,EAAE,cAAc,CAAC,GAAG,SAAS,GACrG,OAAO,CAAC,mBAAmB,GAAG,SAAS,CAAC;CAwB3C;AAED,MAAM,MAAM,aAAa,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnE;;;;GAIG;AACH,wBAAsB,oBAAoB,CACzC,UAAU,EAAE,wBAAwB,EACpC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,MAAM,EACd,OAAO,GAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAAO,GACjD,OAAO,CAAC,aAAa,GAAG,SAAS,CAAC,CAapC"}