@code-yeongyu/senpi 2026.8.26 → 2026.8.28
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.
- package/CHANGELOG.md +76 -0
- package/README.md +3 -3
- package/dist/cli/args.d.ts +3 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +14 -1
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +10 -1
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/bash-executor.d.ts.map +1 -1
- package/dist/core/bash-executor.js +67 -29
- package/dist/core/bash-executor.js.map +1 -1
- package/dist/core/credential-accounts.d.ts +31 -0
- package/dist/core/credential-accounts.d.ts.map +1 -0
- package/dist/core/credential-accounts.js +125 -0
- package/dist/core/credential-accounts.js.map +1 -0
- package/dist/core/credential-pool/classify.d.ts +42 -0
- package/dist/core/credential-pool/classify.d.ts.map +1 -0
- package/dist/core/credential-pool/classify.js +75 -0
- package/dist/core/credential-pool/classify.js.map +1 -0
- package/dist/core/credential-pool/env-slots.d.ts +22 -0
- package/dist/core/credential-pool/env-slots.d.ts.map +1 -0
- package/dist/core/credential-pool/env-slots.js +37 -0
- package/dist/core/credential-pool/env-slots.js.map +1 -0
- package/dist/core/credential-pool/failover.d.ts +54 -0
- package/dist/core/credential-pool/failover.d.ts.map +1 -0
- package/dist/core/credential-pool/failover.js +106 -0
- package/dist/core/credential-pool/failover.js.map +1 -0
- package/dist/core/credential-pool/rotation-stream.d.ts +43 -0
- package/dist/core/credential-pool/rotation-stream.d.ts.map +1 -0
- package/dist/core/credential-pool/rotation-stream.js +96 -0
- package/dist/core/credential-pool/rotation-stream.js.map +1 -0
- package/dist/core/credential-pool/state-store.d.ts +89 -0
- package/dist/core/credential-pool/state-store.d.ts.map +1 -0
- package/dist/core/credential-pool/state-store.js +166 -0
- package/dist/core/credential-pool/state-store.js.map +1 -0
- package/dist/core/extensions/builtin/account/index.d.ts +9 -0
- package/dist/core/extensions/builtin/account/index.d.ts.map +1 -0
- package/dist/core/extensions/builtin/account/index.js +69 -0
- package/dist/core/extensions/builtin/account/index.js.map +1 -0
- package/dist/core/extensions/builtin/compaction/index.d.ts.map +1 -1
- package/dist/core/extensions/builtin/compaction/index.js +67 -0
- package/dist/core/extensions/builtin/compaction/index.js.map +1 -1
- package/dist/core/extensions/builtin/compaction/log.d.ts +1 -1
- package/dist/core/extensions/builtin/compaction/log.d.ts.map +1 -1
- package/dist/core/extensions/builtin/compaction/log.js +1 -0
- package/dist/core/extensions/builtin/compaction/log.js.map +1 -1
- package/dist/core/extensions/builtin/config-reload/index.js +14 -22
- package/dist/core/extensions/builtin/config-reload/index.js.map +1 -1
- package/dist/core/extensions/builtin/goal/continuation-recovery.d.ts +1 -0
- package/dist/core/extensions/builtin/goal/continuation-recovery.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/continuation-recovery.js +2 -0
- package/dist/core/extensions/builtin/goal/continuation-recovery.js.map +1 -1
- package/dist/core/extensions/builtin/goal/continuation.d.ts +8 -1
- package/dist/core/extensions/builtin/goal/continuation.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/continuation.js +15 -0
- package/dist/core/extensions/builtin/goal/continuation.js.map +1 -1
- package/dist/core/extensions/builtin/goal/direct-input-lifecycle.js +2 -2
- package/dist/core/extensions/builtin/goal/direct-input-lifecycle.js.map +1 -1
- package/dist/core/extensions/builtin/goal/format.d.ts +6 -1
- package/dist/core/extensions/builtin/goal/format.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/format.js +6 -0
- package/dist/core/extensions/builtin/goal/format.js.map +1 -1
- package/dist/core/extensions/builtin/goal/lifecycle-helpers.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/lifecycle-helpers.js +5 -2
- package/dist/core/extensions/builtin/goal/lifecycle-helpers.js.map +1 -1
- package/dist/core/extensions/builtin/goal/monitor-continuation.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/monitor-continuation.js +31 -7
- package/dist/core/extensions/builtin/goal/monitor-continuation.js.map +1 -1
- package/dist/core/extensions/builtin/goal/persistence.js +2 -0
- package/dist/core/extensions/builtin/goal/persistence.js.map +1 -1
- package/dist/core/extensions/builtin/goal/renderers.d.ts +14 -0
- package/dist/core/extensions/builtin/goal/renderers.d.ts.map +1 -0
- package/dist/core/extensions/builtin/goal/renderers.js +153 -0
- package/dist/core/extensions/builtin/goal/renderers.js.map +1 -0
- package/dist/core/extensions/builtin/goal/store.d.ts +6 -2
- package/dist/core/extensions/builtin/goal/store.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/store.js +8 -2
- package/dist/core/extensions/builtin/goal/store.js.map +1 -1
- package/dist/core/extensions/builtin/goal/tool-registration.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/tool-registration.js +16 -6
- package/dist/core/extensions/builtin/goal/tool-registration.js.map +1 -1
- package/dist/core/extensions/builtin/goal/types.d.ts +1 -0
- package/dist/core/extensions/builtin/goal/types.d.ts.map +1 -1
- package/dist/core/extensions/builtin/goal/types.js.map +1 -1
- package/dist/core/extensions/builtin/imagegen/index.d.ts.map +1 -1
- package/dist/core/extensions/builtin/imagegen/index.js +10 -3
- package/dist/core/extensions/builtin/imagegen/index.js.map +1 -1
- package/dist/core/extensions/builtin/index.d.ts.map +1 -1
- package/dist/core/extensions/builtin/index.js +4 -0
- package/dist/core/extensions/builtin/index.js.map +1 -1
- package/dist/core/extensions/builtin/todotools/index.js +1 -1
- package/dist/core/extensions/builtin/todotools/index.js.map +1 -1
- package/dist/core/extensions/builtin/todotools/native-todo-mirror.d.ts +1 -1
- package/dist/core/extensions/builtin/todotools/native-todo-mirror.d.ts.map +1 -1
- package/dist/core/extensions/builtin/todotools/native-todo-mirror.js +1 -1
- package/dist/core/extensions/builtin/todotools/native-todo-mirror.js.map +1 -1
- package/dist/core/hidden-stdout-log.d.ts +10 -0
- package/dist/core/hidden-stdout-log.d.ts.map +1 -1
- package/dist/core/hidden-stdout-log.js +36 -5
- package/dist/core/hidden-stdout-log.js.map +1 -1
- package/dist/core/model-config-schema.d.ts +85 -12
- package/dist/core/model-config-schema.d.ts.map +1 -1
- package/dist/core/model-config-schema.js +25 -0
- package/dist/core/model-config-schema.js.map +1 -1
- package/dist/core/model-runtime.d.ts +34 -1
- package/dist/core/model-runtime.d.ts.map +1 -1
- package/dist/core/model-runtime.js +98 -5
- package/dist/core/model-runtime.js.map +1 -1
- package/dist/core/tools/output-accumulator.d.ts +2 -0
- package/dist/core/tools/output-accumulator.d.ts.map +1 -1
- package/dist/core/tools/output-accumulator.js +32 -7
- package/dist/core/tools/output-accumulator.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +38 -3
- package/dist/main.js.map +1 -1
- package/dist/modes/app-server/server/account.d.ts.map +1 -1
- package/dist/modes/app-server/server/account.js +5 -5
- package/dist/modes/app-server/server/account.js.map +1 -1
- package/dist/modes/index.d.ts +1 -0
- package/dist/modes/index.d.ts.map +1 -1
- package/dist/modes/index.js +1 -0
- package/dist/modes/index.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts +11 -3
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +31 -1
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/components/tool-execution-fallback.d.ts.map +1 -1
- package/dist/modes/interactive/components/tool-execution-fallback.js +82 -1
- package/dist/modes/interactive/components/tool-execution-fallback.js.map +1 -1
- package/dist/modes/interactive/grok/chrome.d.ts +4 -4
- package/dist/modes/interactive/grok/chrome.d.ts.map +1 -1
- package/dist/modes/interactive/grok/chrome.js.map +1 -1
- package/dist/modes/interactive/grok/footer.d.ts +3 -3
- package/dist/modes/interactive/grok/footer.d.ts.map +1 -1
- package/dist/modes/interactive/grok/footer.js.map +1 -1
- package/dist/modes/interactive/interactive-host-runtime.d.ts +44 -0
- package/dist/modes/interactive/interactive-host-runtime.d.ts.map +1 -0
- package/dist/modes/interactive/interactive-host-runtime.js +271 -0
- package/dist/modes/interactive/interactive-host-runtime.js.map +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +65 -21
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/connection-handler.d.ts.map +1 -1
- package/dist/modes/rpc/connection-handler.js +19 -6
- package/dist/modes/rpc/connection-handler.js.map +1 -1
- package/dist/modes/rpc/host-ensure.d.ts +41 -0
- package/dist/modes/rpc/host-ensure.d.ts.map +1 -0
- package/dist/modes/rpc/host-ensure.js +245 -0
- package/dist/modes/rpc/host-ensure.js.map +1 -0
- package/dist/modes/rpc/host-lifecycle.d.ts +57 -0
- package/dist/modes/rpc/host-lifecycle.d.ts.map +1 -0
- package/dist/modes/rpc/host-lifecycle.js +491 -0
- package/dist/modes/rpc/host-lifecycle.js.map +1 -0
- package/dist/modes/rpc/host-watchdog.d.ts +33 -0
- package/dist/modes/rpc/host-watchdog.d.ts.map +1 -0
- package/dist/modes/rpc/host-watchdog.js +142 -0
- package/dist/modes/rpc/host-watchdog.js.map +1 -0
- package/dist/modes/rpc/multi-session-host.d.ts +7 -1
- package/dist/modes/rpc/multi-session-host.d.ts.map +1 -1
- package/dist/modes/rpc/multi-session-host.js +206 -32
- package/dist/modes/rpc/multi-session-host.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +41 -3
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +125 -14
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +1 -1
- package/dist/modes/rpc/rpc-mode.js +1 -1
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +6 -2
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/dist/modes/rpc/session-command-router.d.ts.map +1 -1
- package/dist/modes/rpc/session-command-router.js +24 -6
- package/dist/modes/rpc/session-command-router.js.map +1 -1
- package/dist/modes/rpc/session-event-writer.d.ts +13 -2
- package/dist/modes/rpc/session-event-writer.d.ts.map +1 -1
- package/dist/modes/rpc/session-event-writer.js +65 -25
- package/dist/modes/rpc/session-event-writer.js.map +1 -1
- package/dist/modes/rpc/session-registry.d.ts +9 -0
- package/dist/modes/rpc/session-registry.d.ts.map +1 -1
- package/dist/modes/rpc/session-registry.js +31 -3
- package/dist/modes/rpc/session-registry.js.map +1 -1
- package/docs/providers.md +48 -0
- package/docs/rpc.md +59 -5
- package/node_modules/@code-yeongyu/senpi-codemode/CHANGELOG.md +44 -0
- package/node_modules/@code-yeongyu/senpi-codemode/package.json +4 -4
- package/node_modules/@code-yeongyu/senpi-codemode/src/kernels/js/context-manager.ts +13 -1
- package/node_modules/@code-yeongyu/senpi-codemode/src/kernels/js/inline-worker.ts +15 -1
- package/node_modules/@code-yeongyu/senpi-codemode/src/kernels/js/worker-indirect-eval.js +711 -1
- package/node_modules/@code-yeongyu/senpi-codemode/src/kernels/py/transport.ts +14 -1
- package/node_modules/@code-yeongyu/senpi-codemode/src/prompt/eval-prompt.ts +10 -10
- package/node_modules/@code-yeongyu/senpi-codemode/src/tool/detached-eval-result.ts +9 -3
- package/node_modules/@code-yeongyu/senpi-codemode/src/tool/eval-tool.ts +6 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/edit.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/edit.js +20 -4
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/edit.js.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/index.d.ts +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/index.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/index.js.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/post-mutate.d.ts +21 -0
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/post-mutate.d.ts.map +1 -0
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/post-mutate.js +27 -0
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/post-mutate.js.map +1 -0
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/tool-context.d.ts +24 -0
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/tool-context.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/tool-context.js.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/write.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/write.js +7 -2
- package/node_modules/@earendil-works/pi-agent-core/dist/harness/tools/write.js.map +1 -1
- package/node_modules/@earendil-works/pi-agent-core/package.json +3 -3
- package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.d.ts +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.js +46 -14
- package/node_modules/@earendil-works/pi-ai/dist/api/anthropic-tool-pairs.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.d.ts +11 -0
- package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.js +30 -16
- package/node_modules/@earendil-works/pi-ai/dist/api/cursor-agent.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/credential-store.d.ts +2 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/credential-store.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/credential-store.js +2 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/credential-store.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.d.ts +16 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.d.ts.map +1 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.js +75 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/classify.js.map +1 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.d.ts +45 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.d.ts.map +1 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.js +81 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/failover.js.map +1 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.d.ts +31 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.d.ts.map +1 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.js +65 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/select.js.map +1 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.d.ts +12 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.js +37 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/pool/slots.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.d.ts +6 -0
- package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.js +32 -6
- package/node_modules/@earendil-works/pi-ai/dist/auth/resolve.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/types.d.ts +3 -2
- package/node_modules/@earendil-works/pi-ai/dist/auth/types.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/auth/types.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.d.ts +6 -0
- package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.js +6 -1
- package/node_modules/@earendil-works/pi-ai/dist/env-api-keys.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.d.ts +75 -0
- package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js +75 -0
- package/node_modules/@earendil-works/pi-ai/dist/image-models.generated.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/.manifest.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/alibaba-token-plan.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/amazon-bedrock.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/baseten.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-ai-gateway.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/cloudflare-workers-ai.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/fireworks.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/github-copilot.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/huggingface.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/minimax-cn.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/minimax.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/mistral.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/nvidia.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/openai-codex.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/openai.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode-go.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/opencode.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/opengateway.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/openrouter.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan-cn.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/qwen-token-plan.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/together.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/vercel-ai-gateway.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai-coding-cn.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/providers/data/zai.json +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js +4 -0
- package/node_modules/@earendil-works/pi-ai/dist/utils/overflow.js.map +1 -1
- package/node_modules/@earendil-works/pi-ai/package.json +5 -5
- package/node_modules/@earendil-works/pi-pty/package.json +1 -1
- package/node_modules/@earendil-works/pi-telemetry/package.json +1 -1
- package/node_modules/@earendil-works/pi-tui/dist/terminal.d.ts +5 -0
- package/node_modules/@earendil-works/pi-tui/dist/terminal.d.ts.map +1 -1
- package/node_modules/@earendil-works/pi-tui/dist/terminal.js +90 -1
- package/node_modules/@earendil-works/pi-tui/dist/terminal.js.map +1 -1
- package/node_modules/@earendil-works/pi-tui/dist/tui.js +1 -1
- package/node_modules/@earendil-works/pi-tui/dist/tui.js.map +1 -1
- package/node_modules/@earendil-works/pi-tui/package.json +1 -1
- package/package.json +7 -7
|
@@ -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"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import lockfile from "proper-lockfile";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { getAgentDir } from "../../config.js";
|
|
7
|
+
import { FILE_STORAGE_LOCK_OPTIONS } from "../lockfile-policy.js";
|
|
8
|
+
export const CREDENTIAL_POOL_STATE_FILENAME = "credential-pool-state.json";
|
|
9
|
+
const HEX_256_BIT = /^[0-9a-f]{64}$/;
|
|
10
|
+
const leaseSchema = z.strictObject({
|
|
11
|
+
id: z.string().min(1).max(128),
|
|
12
|
+
expiresAt: z.number().int().positive(),
|
|
13
|
+
});
|
|
14
|
+
const slotStateSchema = z.strictObject({
|
|
15
|
+
stateVersion: z.number().int().nonnegative(),
|
|
16
|
+
blockedUntil: z.number().int().positive().optional(),
|
|
17
|
+
blockReason: z.enum(["auth_error", "rate_limit", "account_disabled"]).optional(),
|
|
18
|
+
failureCount: z.number().int().nonnegative().optional(),
|
|
19
|
+
lastSuccessAt: z.number().int().positive().optional(),
|
|
20
|
+
credentialRevision: z.string().regex(HEX_256_BIT).optional(),
|
|
21
|
+
lease: leaseSchema.optional(),
|
|
22
|
+
});
|
|
23
|
+
const documentSchema = z.strictObject({
|
|
24
|
+
schemaVersion: z.literal(1),
|
|
25
|
+
installationKey: z.string().regex(HEX_256_BIT),
|
|
26
|
+
providers: z.record(z.string(), z.strictObject({
|
|
27
|
+
lanes: z.record(z.string(), z.strictObject({
|
|
28
|
+
slots: z.record(z.string(), slotStateSchema),
|
|
29
|
+
})),
|
|
30
|
+
})),
|
|
31
|
+
});
|
|
32
|
+
export function credentialPoolStatePath(agentDir = getAgentDir()) {
|
|
33
|
+
return join(agentDir, CREDENTIAL_POOL_STATE_FILENAME);
|
|
34
|
+
}
|
|
35
|
+
const FILE_WRITE_OPTIONS = { encoding: "utf-8", mode: 0o600 };
|
|
36
|
+
function freshDocument() {
|
|
37
|
+
return { schemaVersion: 1, installationKey: randomBytes(32).toString("hex"), providers: {} };
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The sidecar holds only health, so an unreadable or invalid document resets to
|
|
41
|
+
* a fresh one instead of failing auth resolution; the worst outcome of a reset
|
|
42
|
+
* is a cleared cooldown, never lost credential material.
|
|
43
|
+
*/
|
|
44
|
+
function parseDocument(content) {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = documentSchema.safeParse(JSON.parse(content));
|
|
47
|
+
return parsed.success ? parsed.data : freshDocument();
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return freshDocument();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Deadlines are absolute so a restart never re-enables a cooling credential early. */
|
|
54
|
+
export function slotHealth(state, now) {
|
|
55
|
+
if (!state)
|
|
56
|
+
return "ready";
|
|
57
|
+
// Auth and billing blocks have no expiry; only credential replacement clears them.
|
|
58
|
+
if (state.blockReason === "auth_error" || state.blockReason === "account_disabled")
|
|
59
|
+
return "blocked";
|
|
60
|
+
if (state.blockedUntil !== undefined && state.blockedUntil > now)
|
|
61
|
+
return "blocked";
|
|
62
|
+
if (state.blockedUntil !== undefined && state.lease !== undefined && state.lease.expiresAt > now) {
|
|
63
|
+
return "half_open";
|
|
64
|
+
}
|
|
65
|
+
return "ready";
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* File-locked health sidecar for credential pools. Never stores credential
|
|
69
|
+
* material: env slots are keyed by an installation-local HMAC revision so a
|
|
70
|
+
* rotated env value clears its own stale block without persisting a raw key
|
|
71
|
+
* hash anywhere.
|
|
72
|
+
*/
|
|
73
|
+
export class CredentialSlotRepository {
|
|
74
|
+
constructor(path = credentialPoolStatePath()) {
|
|
75
|
+
this.path = path;
|
|
76
|
+
}
|
|
77
|
+
ensureFile() {
|
|
78
|
+
if (existsSync(this.path))
|
|
79
|
+
return;
|
|
80
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
81
|
+
writeFileSync(this.path, JSON.stringify(freshDocument(), null, 2), FILE_WRITE_OPTIONS);
|
|
82
|
+
}
|
|
83
|
+
async withDocument(fn) {
|
|
84
|
+
this.ensureFile();
|
|
85
|
+
const release = await lockfile.lock(this.path, FILE_STORAGE_LOCK_OPTIONS);
|
|
86
|
+
try {
|
|
87
|
+
const document = parseDocument(readFileSync(this.path, "utf-8"));
|
|
88
|
+
const { result, next } = fn(document);
|
|
89
|
+
if (next)
|
|
90
|
+
writeFileSync(this.path, JSON.stringify(next, null, 2), FILE_WRITE_OPTIONS);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async installationKey() {
|
|
98
|
+
return this.withDocument((document) => ({ result: document.installationKey, next: document }));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Derives the persistence key for an env slot's health. HMAC over the
|
|
102
|
+
* installation key means the revision is useless outside this installation
|
|
103
|
+
* and never equals a raw hash of the key material.
|
|
104
|
+
*/
|
|
105
|
+
async envCredentialRevision(envVarName, envValue) {
|
|
106
|
+
const key = await this.installationKey();
|
|
107
|
+
return createHmac("sha256", key).update(`${envVarName}\0${envValue}`).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
async listSlots(providerId, laneId) {
|
|
110
|
+
return this.withDocument((document) => ({
|
|
111
|
+
result: { ...document.providers[providerId]?.lanes[laneId]?.slots },
|
|
112
|
+
}));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Atomic read-modify-write for one slot's health. `stateVersion` increments
|
|
116
|
+
* on every write so concurrent mutators can detect lost updates; returning
|
|
117
|
+
* undefined removes the slot state entirely.
|
|
118
|
+
*/
|
|
119
|
+
async mutateSlotState(providerId, laneId, slotId, fn) {
|
|
120
|
+
return this.withDocument((document) => {
|
|
121
|
+
const provider = document.providers[providerId] ?? { lanes: {} };
|
|
122
|
+
const lane = provider.lanes[laneId] ?? { slots: {} };
|
|
123
|
+
const current = lane.slots[slotId];
|
|
124
|
+
const mutated = fn(current);
|
|
125
|
+
const slots = { ...lane.slots };
|
|
126
|
+
let result;
|
|
127
|
+
if (mutated === undefined) {
|
|
128
|
+
delete slots[slotId];
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
result = { ...mutated, stateVersion: (current?.stateVersion ?? 0) + 1 };
|
|
132
|
+
slots[slotId] = result;
|
|
133
|
+
}
|
|
134
|
+
const next = {
|
|
135
|
+
...document,
|
|
136
|
+
providers: {
|
|
137
|
+
...document.providers,
|
|
138
|
+
[providerId]: { lanes: { ...provider.lanes, [laneId]: { slots } } },
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
return { result, next };
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Transitions an expired cooldown atomically to half_open: exactly one caller
|
|
147
|
+
* wins the probe lease; everyone else keeps treating the slot as unavailable
|
|
148
|
+
* until the lease expires or the probe settles the block.
|
|
149
|
+
*/
|
|
150
|
+
export async function acquireHalfOpenLease(repository, providerId, laneId, slotId, options = {}) {
|
|
151
|
+
const now = options.now ?? Date.now();
|
|
152
|
+
const leaseTtlMs = options.leaseTtlMs ?? 30_000;
|
|
153
|
+
let lease;
|
|
154
|
+
await repository.mutateSlotState(providerId, laneId, slotId, (current) => {
|
|
155
|
+
if (!current)
|
|
156
|
+
return current;
|
|
157
|
+
const expired = current.blockedUntil !== undefined && current.blockedUntil <= now;
|
|
158
|
+
const leaseLive = current.lease !== undefined && current.lease.expiresAt > now;
|
|
159
|
+
if (!expired || leaseLive || current.blockReason === "auth_error")
|
|
160
|
+
return current;
|
|
161
|
+
lease = { leaseId: randomUUID(), expiresAt: now + leaseTtlMs };
|
|
162
|
+
return { ...current, lease: { id: lease.leaseId, expiresAt: lease.expiresAt } };
|
|
163
|
+
});
|
|
164
|
+
return lease;
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=state-store.js.map
|