cartethyia 2.7.0 → 2.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +107 -106
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
- package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
- package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
- package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
- package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
- package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
- package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
- package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
- package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
- package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
- package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
- package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
- package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
- package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
- package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
- package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
- package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
- package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
- package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
- package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
- package/dist/dashboard/assets/providers/inception.svg +5 -0
- package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
- package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
- package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
- package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
- package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
- package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
- package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +54 -53
- package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
- package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
- package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
- package/package.json +96 -96
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +684 -714
- package/src/boot/dependencies.ts +65 -62
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +70 -70
- package/src/config/security.ts +37 -37
- package/src/config/spec.ts +371 -358
- package/src/console/cli-tools/contracts.ts +10 -4
- package/src/console/console-router.ts +233 -225
- package/src/console/domain-registration.ts +398 -381
- package/src/console/providers/catalog/catalog-projections.ts +2 -6
- package/src/console/providers/catalog/contracts.ts +19 -0
- package/src/console/providers/catalog/provider-operations.ts +38 -0
- package/src/console/providers/catalog/routes.ts +14 -0
- package/src/console/providers/catalog/store-helpers.ts +326 -0
- package/src/console/providers/catalog/store.ts +1292 -1563
- package/src/console/quota/account-quota-global.ts +2 -2
- package/src/console/quota/account-quota-shared.ts +1 -1
- package/src/console/quota/account-quota-tenant.ts +35 -12
- package/src/console/quota/quota-view.ts +2 -2
- package/src/console/routing/pools/contracts.ts +41 -1
- package/src/console/routing/pools/operations.ts +183 -4
- package/src/console/routing/pools/relay-deploy.ts +17 -4
- package/src/console/routing/pools/routes.ts +30 -0
- package/src/console/routing/pools/store.ts +98 -8
- package/src/main.ts +183 -167
- package/src/network/outbound-fetch.ts +124 -208
- package/src/network/pool/agent.ts +15 -0
- package/src/network/pool/pool-state.ts +7 -0
- package/src/network/pool/selector.ts +110 -2
- package/src/network/pool-cooldown-store.ts +252 -0
- package/src/network/pool-health-machine.ts +5 -3
- package/src/network/pool-health.ts +36 -9
- package/src/observability/event-query.ts +57 -0
- package/src/observability/metrics.ts +467 -467
- package/src/observability/runtime-metrics.ts +269 -269
- package/src/persistence/health-event-flush.ts +2 -0
- package/src/persistence/schema.ts +19 -0
- package/src/persistence/usage-flush.ts +8 -3
- package/src/protocol/canonical-model.ts +1 -1
- package/src/protocol/dsml.ts +377 -0
- package/src/protocol/gateway-error.ts +232 -232
- package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
- package/src/protocol/request/chat.ts +7 -7
- package/src/protocol/request/codex.ts +2 -2
- package/src/protocol/request/gemini.ts +1 -1
- package/src/protocol/request/messages.ts +6 -6
- package/src/protocol/request/responses.ts +7 -7
- package/src/protocol/response/dsml.ts +12 -133
- package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
- package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
- package/src/{transport → protocol}/translation/capabilities.ts +2 -2
- package/src/{transport → protocol}/translation/normalize.ts +1 -1
- package/src/{transport → protocol}/translation/quirks.ts +1 -1
- package/src/{transport → protocol}/translation/search.ts +1 -1
- package/src/{transport → protocol}/translation/thinking.ts +23 -1
- package/src/{transport → protocol}/translation/tool-id.ts +1 -1
- package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
- package/src/protocol/upstream-parsing.ts +176 -0
- package/src/providers/adapters/compatible-adapter.ts +11 -2
- package/src/providers/authentication/oauth-refresh-service.ts +9 -5
- package/src/providers/default-registry.ts +22 -5
- package/src/providers/discovery/probe-phases.ts +16 -3
- package/src/providers/discovery/probing-service.ts +4 -1
- package/src/providers/integrations/anthropic.ts +1 -1
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
- package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
- package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
- package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
- package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
- package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
- package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
- package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
- package/src/providers/integrations/claude/claude.ts +1 -1
- package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
- package/src/providers/integrations/codex/codex-errors.ts +1 -1
- package/src/providers/integrations/codex/codex.ts +1 -1
- package/src/providers/integrations/factory/factory.ts +1 -1
- package/src/providers/integrations/github/github.ts +1 -1
- package/src/providers/integrations/grok/grok.ts +1 -1
- package/src/providers/integrations/inception/provider.ts +91 -0
- package/src/providers/integrations/inferhub.ts +1 -1
- package/src/providers/integrations/kiro/kiro-request.ts +1 -1
- package/src/providers/integrations/mistral.ts +193 -19
- package/src/providers/integrations/ollamacloud-models.ts +50 -0
- package/src/providers/integrations/opencode/fingerprint.ts +19 -2
- package/src/providers/integrations/opencode/prompt.ts +111 -0
- package/src/providers/integrations/opencode/provider.ts +101 -23
- package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
- package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
- package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
- package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
- package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
- package/src/providers/integrations/qoder/qoder.ts +315 -400
- package/src/providers/integrations/shared/install-id.ts +2 -1
- package/src/providers/integrations/trae/trae-cache.ts +110 -0
- package/src/providers/integrations/trae/trae-oauth.ts +29 -4
- package/src/providers/integrations/trae/trae-protocol.ts +2 -1
- package/src/providers/integrations/trae/trae.ts +106 -23
- package/src/providers/operations/account-error-classification.ts +1684 -0
- package/src/providers/operations/account-health-service.ts +27 -1503
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/operations/provider-catalog-service.ts +11 -1
- package/src/providers/provider-contracts.ts +7 -4
- package/src/providers/provider-metadata.ts +25 -4
- package/src/providers/provider-probe.ts +2 -2
- package/src/{console → providers}/quota/cache.ts +1 -1
- package/src/{console → providers}/quota/refresh.ts +5 -5
- package/src/security/cli-client-fingerprint.ts +9 -2
- package/src/transport/dispatch/attempt-finalize.ts +114 -12
- package/src/transport/dispatch/attempt-loop.ts +3 -0
- package/src/transport/dispatch/empty-stream.ts +9 -2
- package/src/transport/dispatch/generation.ts +37 -55
- package/src/transport/dispatch/handler-deps.ts +32 -0
- package/src/transport/dispatch/leases.ts +9 -4
- package/src/transport/dispatch/proxy-request.ts +6 -30
- package/src/transport/dispatch/responses-compact.ts +22 -43
- package/src/transport/dispatch/streaming/finalize.ts +3 -0
- package/src/transport/dispatch/streaming/stream.ts +0 -8
- package/src/transport/dispatch/systemone.ts +22 -42
- package/src/transport/dispatch/token-count.ts +23 -39
- package/src/transport/dispatch/upstream.ts +1 -3
- package/src/transport/dispatch/websearch.ts +22 -41
- package/src/transport/failure-policy.ts +92 -178
- package/src/transport/guards/abuse-guard.ts +10 -0
- package/src/transport/guards/auth-guard.ts +109 -109
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +220 -220
- package/src/transport/guards/request-context.ts +245 -238
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -114
- package/src/transport/request/preparer.ts +6 -6
- package/src/transport/request/state.ts +66 -38
- package/src/transport/request/tenant-preferences.ts +1 -1
- package/src/transport/routing/route-catalog.ts +75 -43
- package/src/transport/routing/router.ts +30 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- package/src/transport/surface/responses/encode.ts +2 -3
- package/src/transport/surface/responses/parse.ts +49 -7
- package/src/types/undici.d.ts +33 -0
- package/src/workers/buddy-growth-tasks.ts +346 -425
- package/src/workers/daily-checkin.ts +17 -6
- package/src/workers/quota-refresh-sweep.ts +2 -2
- package/src/workers/scheduler.ts +17 -1
- package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
- package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
- package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
- package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
- package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
- package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
- package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
- package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
- package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
- package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
- package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
- package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
- package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
- package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
- package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
- package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
- package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
- package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
- package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
- package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
- package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
- package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
- package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
- package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
- package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
- package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
- package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
- package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
- package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
- package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
- package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
- package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
- package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
// Validated fetch: SSRF-guarded outbound fetch with optional pool-agent support.
|
|
2
2
|
import { GatewayError } from "../protocol/gateway-error";
|
|
3
|
-
import { Agent as HttpAgent
|
|
4
|
-
import { Agent as HttpsAgent
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { Agent as HttpAgent } from "node:http";
|
|
4
|
+
import { Agent as HttpsAgent } from "node:https";
|
|
5
|
+
import { Agent as UndiciAgent, fetch as undiciFetch, type UndiciRequestInit } from "undici";
|
|
6
|
+
import { SocksProxyAgent } from "socks-proxy-agent";
|
|
7
7
|
import { resolveAllAddresses, validateResolvedAddresses } from "./ssrf";
|
|
8
8
|
import type { SsrfPolicy } from "../config/network";
|
|
9
9
|
import { isProxyAgentPair, type ProxyAgentPair } from "./pool/agent";
|
|
10
10
|
import { metrics } from "../observability/metrics";
|
|
11
11
|
import { trackNetworkCall } from "../observability/performance-metrics";
|
|
12
|
-
import { stripResponseHeaders } from "./response-headers";
|
|
13
12
|
// Validated fetch — single egress with ProxyAgent support
|
|
14
13
|
export interface ValidatedFetchOptions {
|
|
15
14
|
readonly fetchFn?: typeof fetch;
|
|
@@ -17,73 +16,25 @@ export interface ValidatedFetchOptions {
|
|
|
17
16
|
readonly maxRedirects?: number;
|
|
18
17
|
readonly resolveFn?: (hostname: string, signal: AbortSignal) => Promise<readonly string[]>;
|
|
19
18
|
readonly agent?: HttpAgent | HttpsAgent | ProxyAgentPair;
|
|
19
|
+
/**
|
|
20
|
+
* Proxy URL to route the dial through, forwarded to the runtime-native fetch
|
|
21
|
+
* (`proxy` option). Pool egress passes the pool's endpoint here; without it
|
|
22
|
+
* a request that should leave through a proxy leaves directly.
|
|
23
|
+
*/
|
|
24
|
+
readonly proxyUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Fetch implementation used for SOCKS5 dials (via the undici dispatcher).
|
|
27
|
+
* Defaults to undici's fetch; injectable for offline tests.
|
|
28
|
+
*/
|
|
29
|
+
readonly socksFetch?: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response>;
|
|
20
30
|
}
|
|
21
31
|
export type ValidatedFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
22
32
|
|
|
23
33
|
/**
|
|
24
|
-
*
|
|
25
|
-
* the
|
|
26
|
-
*
|
|
34
|
+
* Whether a configured pool/relay agent owns this dial. When it does, the
|
|
35
|
+
* proxy resolves the upstream target itself, so the local resolve below is an
|
|
36
|
+
* advisory SSRF check rather than a prerequisite for egress.
|
|
27
37
|
*/
|
|
28
|
-
function agentForTarget(
|
|
29
|
-
agent: HttpAgent | HttpsAgent | ProxyAgentPair | undefined,
|
|
30
|
-
protocol: string,
|
|
31
|
-
): HttpAgent | HttpsAgent | undefined {
|
|
32
|
-
if (!agent) return undefined;
|
|
33
|
-
if (isProxyAgentPair(agent)) return protocol === "https:" ? agent.https : agent.http;
|
|
34
|
-
return agent;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Keep-alive options for the direct (non-pool) egress agents.
|
|
39
|
-
*
|
|
40
|
-
* Matched to `network/pool/agent.ts`, which builds pool agents from
|
|
41
|
-
* `resolveProxyMaxSockets()` / `resolveProxyMaxFreeSockets()` and
|
|
42
|
-
* `PROXY_KEEP_ALIVE_TIMEOUT_MS`. Direct egress has no per-pool config to
|
|
43
|
-
* read, so it uses the documented defaults those resolvers return.
|
|
44
|
-
*/
|
|
45
|
-
const DIRECT_AGENT_OPTIONS = {
|
|
46
|
-
keepAlive: true,
|
|
47
|
-
maxSockets: 100,
|
|
48
|
-
maxFreeSockets: 20,
|
|
49
|
-
timeout: 60_000,
|
|
50
|
-
} as const;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* An agent whose socket key includes the SSRF-validated address.
|
|
54
|
-
*
|
|
55
|
-
* The direct (non-pool) path pins egress by passing a `lookup` that returns the
|
|
56
|
-
* one validated address, but `Agent#getName` — the keep-alive socket key —
|
|
57
|
-
* hashes only host/port/localAddress/family. Two requests to the same
|
|
58
|
-
* `host:port` that validated to *different* addresses therefore share a pooled
|
|
59
|
-
* socket, and the second one silently egresses to the first one's address:
|
|
60
|
-
* the pin is bypassed without any error surfacing.
|
|
61
|
-
*
|
|
62
|
-
* Including the address in the key makes each validated address its own socket
|
|
63
|
-
* bucket, so a re-resolved address always dials fresh while the *same* address
|
|
64
|
-
* still reuses its socket (keep-alive is preserved, not defeated).
|
|
65
|
-
*
|
|
66
|
-
* This is a real gap, not a theoretical one: with no agent at all the request
|
|
67
|
-
* falls back to the global agent, which has the same key shape *and* is shared
|
|
68
|
-
* with unrelated traffic.
|
|
69
|
-
*/
|
|
70
|
-
class PinnedAddressAgent extends HttpAgent {
|
|
71
|
-
override getName(options: Record<string, unknown> = {}): string {
|
|
72
|
-
const base = super.getName(options as never);
|
|
73
|
-
const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
|
|
74
|
-
return `${base}:${pinned ?? "-"}`;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
class PinnedAddressHttpsAgent extends HttpsAgent {
|
|
78
|
-
override getName(options: Record<string, unknown> = {}): string {
|
|
79
|
-
const base = super.getName(options as never);
|
|
80
|
-
const pinned = (options["lookup"] as { pinnedAddress?: string } | undefined)?.pinnedAddress;
|
|
81
|
-
return `${base}:${pinned ?? "-"}`;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const DIRECT_HTTP_AGENT = new PinnedAddressAgent(DIRECT_AGENT_OPTIONS);
|
|
86
|
-
const DIRECT_HTTPS_AGENT = new PinnedAddressHttpsAgent(DIRECT_AGENT_OPTIONS);
|
|
87
38
|
|
|
88
39
|
/**
|
|
89
40
|
* Builds the application-relay request used by hosted relay front doors.
|
|
@@ -110,162 +61,127 @@ function relayRequest(
|
|
|
110
61
|
return { url: relayUrl, init: { ...init, headers } };
|
|
111
62
|
}
|
|
112
63
|
|
|
64
|
+
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
|
|
65
|
+
|
|
66
|
+
function stripCredentialsOnCrossOrigin(
|
|
67
|
+
headersInit: HeadersInit | undefined,
|
|
68
|
+
): Headers {
|
|
69
|
+
const headers = new Headers(headersInit);
|
|
70
|
+
for (const name of CREDENTIAL_HEADERS) headers.delete(name);
|
|
71
|
+
return headers;
|
|
72
|
+
}
|
|
73
|
+
|
|
113
74
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* Returns the stream untouched for `identity`, an unknown encoding, or when no
|
|
127
|
-
* `content-encoding` is present.
|
|
75
|
+
* The injected dialer. `fetchFn` is the seam offline suites use to record
|
|
76
|
+
* requests without a socket; the runtime fetch is the only other dialer.
|
|
77
|
+
*/
|
|
78
|
+
function dialer(options: ValidatedFetchOptions): typeof fetch {
|
|
79
|
+
return options.fetchFn ?? fetch;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Builds the `undici` dispatcher that routes a dial through a SOCKS5 pool
|
|
84
|
+
* agent. The runtime-native fetch does not support `socks5://` proxies
|
|
85
|
+
* (`UnsupportedProxyProtocol`), so the socks pair drives the transport via
|
|
86
|
+
* undici's dispatcher instead — no node `http`/`https` adapter is involved.
|
|
128
87
|
*/
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
88
|
+
let socksDispatcherCache: WeakMap<SocksProxyAgent, UndiciAgent> | undefined;
|
|
89
|
+
function socksDispatcherFor(agent: SocksProxyAgent): UndiciAgent {
|
|
90
|
+
socksDispatcherCache ??= new WeakMap();
|
|
91
|
+
let dispatcher = socksDispatcherCache.get(agent);
|
|
92
|
+
if (!dispatcher) {
|
|
93
|
+
dispatcher = new UndiciAgent({
|
|
94
|
+
connect: async (opts) => {
|
|
95
|
+
const o = opts as {
|
|
96
|
+
hostname?: string;
|
|
97
|
+
port?: number | string;
|
|
98
|
+
family?: number;
|
|
99
|
+
lookup?: (hostname: string, options: unknown, cb: (err: Error | null, addresses: unknown) => void) => void;
|
|
100
|
+
};
|
|
101
|
+
return await agent.connect(
|
|
102
|
+
{} as never,
|
|
103
|
+
{
|
|
104
|
+
host: o.hostname ?? "localhost",
|
|
105
|
+
port: Number(o.port ?? 80),
|
|
106
|
+
...(o.family === undefined ? {} : { family: o.family }),
|
|
107
|
+
...(o.lookup === undefined ? {} : { lookup: o.lookup }),
|
|
108
|
+
} as never,
|
|
109
|
+
);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
socksDispatcherCache.set(agent, dispatcher);
|
|
136
113
|
}
|
|
137
|
-
|
|
138
|
-
normalized === "gzip" || normalized === "x-gzip"
|
|
139
|
-
? createGunzip()
|
|
140
|
-
: normalized === "deflate"
|
|
141
|
-
? createInflate()
|
|
142
|
-
: normalized === "br"
|
|
143
|
-
? createBrotliDecompress()
|
|
144
|
-
: undefined;
|
|
145
|
-
// An encoding we cannot decode (e.g. zstd) is left alone: forwarding the
|
|
146
|
-
// header with the raw body keeps the caller's own decoder in charge, which is
|
|
147
|
-
// strictly better than claiming a plain body we never decoded.
|
|
148
|
-
if (decoder === undefined) return { stream: body, encoding: normalized };
|
|
149
|
-
body.on("error", (error) => decoder.destroy(error));
|
|
150
|
-
return { stream: body.pipe(decoder), encoding: undefined };
|
|
114
|
+
return dispatcher;
|
|
151
115
|
}
|
|
152
116
|
|
|
153
|
-
|
|
117
|
+
/**
|
|
118
|
+
* One dial, always through `fetch`.
|
|
119
|
+
*
|
|
120
|
+
* The runtime's own fetch (Bun/Undici) is the transport the clients this
|
|
121
|
+
* gateway impersonates actually use, and several upstreams meter per client
|
|
122
|
+
* transport fingerprint — OpenCode's anonymous free tier refuses a
|
|
123
|
+
* Node-stack dial with 429 FreeUsageLimitError while accepting the identical
|
|
124
|
+
* bytes over this one. There is no Node `http`/`https` path to choose
|
|
125
|
+
* between: the SSRF resolve/validate pass that runs before this dial is the
|
|
126
|
+
* security boundary, and it is identical for every caller.
|
|
127
|
+
*
|
|
128
|
+
* A configured pool/relay agent is still honored through `dispatcher` when the
|
|
129
|
+
* runtime supports it (Bun and Undici both do); when it is not supported the
|
|
130
|
+
* agent is ignored rather than silently replaced by an unvalidated dial.
|
|
131
|
+
*
|
|
132
|
+
* `content-length` is set for a known-length body only when the caller did not
|
|
133
|
+
* set it: without the header the request falls back to chunked encoding, and
|
|
134
|
+
* some upstream gateways wait for the complete chunked stream before routing
|
|
135
|
+
* the request — an avoidable round trip per dial.
|
|
136
|
+
*/
|
|
137
|
+
async function dial(
|
|
154
138
|
url: URL,
|
|
155
139
|
init: RequestInit,
|
|
156
|
-
|
|
157
|
-
|
|
140
|
+
fetchFn: typeof fetch,
|
|
141
|
+
proxyUrl: string | undefined,
|
|
142
|
+
dispatcher?: UndiciAgent,
|
|
143
|
+
socksFetch: (input: RequestInfo | URL, init?: UndiciRequestInit) => Promise<Response> = undiciFetch,
|
|
158
144
|
): Promise<Response> {
|
|
159
|
-
const request = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
160
145
|
const headers = new Headers(init.headers);
|
|
161
|
-
// String/Buffer/Uint8Array bodies go direct: routing them through
|
|
162
|
-
// `new Response(body).arrayBuffer()` copied the whole body per request.
|
|
163
146
|
const rawBody = init.body;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
outboundHeaders[key] = value;
|
|
173
|
-
});
|
|
174
|
-
// A known-length JSON/string body must not fall back to chunked encoding.
|
|
175
|
-
// Upstream gateways may wait for the complete chunked stream before routing
|
|
176
|
-
// the request, adding an avoidable RTT compared with global fetch/Undici.
|
|
177
|
-
if (body !== undefined && !headers.has("content-length")) {
|
|
178
|
-
outboundHeaders["content-length"] =
|
|
179
|
-
typeof body === "string" ? String(Buffer.byteLength(body, "utf8")) : String(body.byteLength);
|
|
147
|
+
if (rawBody !== undefined && rawBody !== null && !headers.has("content-length")) {
|
|
148
|
+
const bytes =
|
|
149
|
+
typeof rawBody === "string"
|
|
150
|
+
? Buffer.byteLength(rawBody, "utf8")
|
|
151
|
+
: Buffer.isBuffer(rawBody) || rawBody instanceof Uint8Array
|
|
152
|
+
? rawBody.byteLength
|
|
153
|
+
: (await new Response(rawBody).arrayBuffer()).byteLength;
|
|
154
|
+
headers.set("content-length", String(bytes));
|
|
180
155
|
}
|
|
181
|
-
|
|
182
|
-
(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
callback(new Error("no validated address available"));
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
const opts = typeof options === "number" ? { family: options } : options;
|
|
196
|
-
const family = resolvedAddress.includes(":") ? 6 : 4;
|
|
197
|
-
if (opts?.all) {
|
|
198
|
-
callback(null, [{ address: resolvedAddress, family }] as never);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
callback(null, resolvedAddress, family);
|
|
202
|
-
},
|
|
203
|
-
// Read by `PinnedAddressAgent#getName` so the keep-alive socket key
|
|
204
|
-
// carries the validated address. See the class comment for why.
|
|
205
|
-
{ pinnedAddress: resolvedAddress },
|
|
206
|
-
) as unknown as NonNullable<RequestOptions["lookup"]>;
|
|
207
|
-
// Direct egress gets a dedicated agent as well as the pinning `lookup`.
|
|
208
|
-
// Passing only `lookup` left the request on the global agent, whose socket
|
|
209
|
-
// key ignores `lookup` — so a re-resolved address could reuse a socket
|
|
210
|
-
// dialed against an earlier one and egress past the SSRF check. The direct
|
|
211
|
-
// agent keys its sockets on the pinned address, so the pin holds.
|
|
212
|
-
const directAgent = url.protocol === "https:" ? DIRECT_HTTPS_AGENT : DIRECT_HTTP_AGENT;
|
|
213
|
-
return new Promise<Response>((resolve, reject) => {
|
|
214
|
-
const nodeRequest = request(
|
|
215
|
-
{
|
|
216
|
-
protocol: url.protocol,
|
|
217
|
-
hostname: url.hostname,
|
|
218
|
-
port: url.port ? url.port : url.protocol === "https:" ? "443" : "80",
|
|
219
|
-
path: `${url.pathname}${url.search}`,
|
|
220
|
-
method: init.method ?? "GET",
|
|
221
|
-
headers: outboundHeaders,
|
|
222
|
-
signal: init.signal ?? undefined,
|
|
223
|
-
...(agent ? { agent } : { agent: directAgent, lookup }),
|
|
224
|
-
...(url.protocol === "https:" ? { servername: url.hostname } : {}),
|
|
225
|
-
},
|
|
226
|
-
(response) => {
|
|
227
|
-
const responseHeaders = stripResponseHeaders(response.headers);
|
|
228
|
-
const { stream, encoding } = decompressBody(response, response.headers["content-encoding"]);
|
|
229
|
-
if (encoding === undefined) {
|
|
230
|
-
// Body is plain (decoded here, or never compressed): the header must
|
|
231
|
-
// not survive, or the caller would try to decode a decoded body.
|
|
232
|
-
responseHeaders.delete("content-encoding");
|
|
233
|
-
}
|
|
234
|
-
// A compressed body this path cannot decode keeps its header so the
|
|
235
|
-
// caller's decoder still applies.
|
|
236
|
-
const webStream = Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;
|
|
237
|
-
resolve(
|
|
238
|
-
new Response(webStream, {
|
|
239
|
-
status: response.statusCode ?? 502,
|
|
240
|
-
headers: responseHeaders,
|
|
241
|
-
}),
|
|
242
|
-
);
|
|
243
|
-
},
|
|
244
|
-
);
|
|
245
|
-
nodeRequest.on("error", reject);
|
|
246
|
-
if (body !== undefined) nodeRequest.write(body);
|
|
247
|
-
nodeRequest.end();
|
|
156
|
+
if (dispatcher !== undefined) {
|
|
157
|
+
return await socksFetch(url, {
|
|
158
|
+
...init,
|
|
159
|
+
headers,
|
|
160
|
+
dispatcher,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
return await fetchFn(url, {
|
|
164
|
+
...init,
|
|
165
|
+
headers,
|
|
166
|
+
...(proxyUrl === undefined ? {} : { proxy: proxyUrl }),
|
|
248
167
|
});
|
|
249
168
|
}
|
|
250
169
|
|
|
251
|
-
const CREDENTIAL_HEADERS = ["authorization", "cookie", "proxy-authorization"] as const;
|
|
252
|
-
|
|
253
|
-
function stripCredentialsOnCrossOrigin(
|
|
254
|
-
headersInit: HeadersInit | undefined,
|
|
255
|
-
): Headers {
|
|
256
|
-
const headers = new Headers(headersInit);
|
|
257
|
-
for (const name of CREDENTIAL_HEADERS) headers.delete(name);
|
|
258
|
-
return headers;
|
|
259
|
-
}
|
|
260
|
-
|
|
261
170
|
export function createValidatedFetch(options: ValidatedFetchOptions = {}): ValidatedFetch {
|
|
262
171
|
const policy = options.policy ?? {};
|
|
263
172
|
const maxRedirects = options.maxRedirects ?? policy.maxRedirects ?? 3;
|
|
264
173
|
const resolveFn = options.resolveFn ?? resolveAllAddresses;
|
|
174
|
+
const fetchFn = dialer(options);
|
|
265
175
|
const configuredAgent = options.agent;
|
|
266
176
|
const relayEndpoint = isProxyAgentPair(configuredAgent)
|
|
267
177
|
? configuredAgent.relayEndpoint
|
|
268
178
|
: undefined;
|
|
179
|
+
const socksAgent = isProxyAgentPair(configuredAgent)
|
|
180
|
+
? configuredAgent.socks
|
|
181
|
+
: undefined;
|
|
182
|
+
const socksFetch = options.socksFetch ?? undiciFetch;
|
|
183
|
+
const proxyUrl =
|
|
184
|
+
options.proxyUrl ?? (isProxyAgentPair(configuredAgent) ? configuredAgent.proxyUrl : undefined);
|
|
269
185
|
return async (input, init = {}) => {
|
|
270
186
|
const startedAt = performance.now();
|
|
271
187
|
let url = input instanceof Request ? new URL(input.url) : new URL(input.toString());
|
|
@@ -319,14 +235,14 @@ export function createValidatedFetch(options: ValidatedFetchOptions = {}): Valid
|
|
|
319
235
|
let response: Response;
|
|
320
236
|
if (relayEndpoint) {
|
|
321
237
|
const relay = relayRequest(relayEndpoint, url, requestInit);
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
238
|
+
// The relay front door is reached directly: it reads the target from
|
|
239
|
+
// `x-relay-target`/`x-relay-path` and performs the upstream fetch
|
|
240
|
+
// itself, so the runtime fetch must NOT be given `proxy` here — that
|
|
241
|
+
// would make it attempt a CONNECT tunnel against the relay, which a
|
|
242
|
+
// serverless front door refuses (400).
|
|
243
|
+
response = await dial(relay.url, relay.init, fetchFn, undefined);
|
|
327
244
|
} else {
|
|
328
|
-
|
|
329
|
-
response = await pinnedFetch(url, requestInit, resolvedAddress!, agent);
|
|
245
|
+
response = await dial(url, requestInit, fetchFn, proxyUrl, socksAgent ? socksDispatcherFor(socksAgent) : undefined, socksFetch);
|
|
330
246
|
}
|
|
331
247
|
if (response.status < 300 || response.status >= 400) {
|
|
332
248
|
trackNetworkCall(url.hostname, performance.now() - startedAt);
|
|
@@ -309,6 +309,18 @@ export interface ProxyAgentPair {
|
|
|
309
309
|
* validated fetcher sends `x-relay-target`/`x-relay-path` to this URL.
|
|
310
310
|
*/
|
|
311
311
|
readonly relayEndpoint?: URL;
|
|
312
|
+
/**
|
|
313
|
+
* The proxy URL this pool dials through, forwarded to the runtime-native
|
|
314
|
+
* fetch (`proxy` option). The native transport has no agent concept, so
|
|
315
|
+
* the URL is what carries the pool egress.
|
|
316
|
+
*/
|
|
317
|
+
readonly proxyUrl?: string;
|
|
318
|
+
/**
|
|
319
|
+
* The SOCKS5 agent instance for socks pools. The runtime-native fetch does
|
|
320
|
+
* not support `socks5://` proxies, so the validated fetcher drives this via
|
|
321
|
+
* the `undici` dispatcher when the pool is a socks pair.
|
|
322
|
+
*/
|
|
323
|
+
readonly socks?: SocksProxyAgent;
|
|
312
324
|
}
|
|
313
325
|
|
|
314
326
|
export function isProxyAgentPair(value: unknown): value is ProxyAgentPair {
|
|
@@ -407,6 +419,7 @@ export function createHttpProxyAgent(
|
|
|
407
419
|
return {
|
|
408
420
|
http: new HttpProxyAgent(proxy, policy, config, poolId),
|
|
409
421
|
https: new HttpsProxyAgent(proxy, policy, config, poolId),
|
|
422
|
+
proxyUrl: proxy.toString(),
|
|
410
423
|
...(isRelayHost(proxy.hostname) ? { relayEndpoint: proxy } : {}),
|
|
411
424
|
};
|
|
412
425
|
}
|
|
@@ -472,6 +485,8 @@ export function createSocks5Agent(
|
|
|
472
485
|
return {
|
|
473
486
|
http: http as unknown as HttpAgent,
|
|
474
487
|
https: https as unknown as HttpsAgent,
|
|
488
|
+
socks: http,
|
|
489
|
+
proxyUrl: withCredential(url, credential).toString(),
|
|
475
490
|
};
|
|
476
491
|
}
|
|
477
492
|
|
|
@@ -28,6 +28,13 @@ export interface ProxyCooldownEntry {
|
|
|
28
28
|
export interface PoolRotation {
|
|
29
29
|
readonly key: string;
|
|
30
30
|
readonly rotateCount: number;
|
|
31
|
+
/**
|
|
32
|
+
* The strategy that produced this rotation request. `round_robin` serves
|
|
33
|
+
* `rotateCount` requests per pool before advancing; `random` scatters each
|
|
34
|
+
* selection across the eligible pools in a fresh random order. Absent means
|
|
35
|
+
* the default weighted least-loaded scan.
|
|
36
|
+
*/
|
|
37
|
+
readonly strategy?: "round_robin" | "random";
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
export interface PoolUsageSnapshot {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { redisEvalNumber, type RedisClient } from "../../persistence/redis";
|
|
11
11
|
import { resolveInflightTtlSeconds } from "../../config/timeouts";
|
|
12
12
|
import { log } from "../../observability/logger";
|
|
13
|
+
import { providerRateLimitIsIpScoped } from "../../providers/provider-metadata";
|
|
13
14
|
import {
|
|
14
15
|
COOLDOWN_CLEAR_SCRIPT,
|
|
15
16
|
COOLDOWN_FLAG_SCRIPT,
|
|
@@ -67,6 +68,21 @@ export function effectiveConcurrency(limit: number): number {
|
|
|
67
68
|
return limit;
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
/**
|
|
72
|
+
* In-place Fisher–Yates shuffle. The `random` pool strategy uses it to scatter
|
|
73
|
+
* each selection across the eligible pools instead of always scanning from a
|
|
74
|
+
* fixed (or cursor-driven) order, so a burst does not clump onto the first
|
|
75
|
+
* pool and burn it while siblings sit idle.
|
|
76
|
+
*/
|
|
77
|
+
function shuffleArray<T>(items: T[]): void {
|
|
78
|
+
for (let i = items.length - 1; i > 0; i -= 1) {
|
|
79
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
80
|
+
const swap = items[i]!;
|
|
81
|
+
items[i] = items[j]!;
|
|
82
|
+
items[j] = swap;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
function normalizedWeight(weight: number | undefined): number {
|
|
71
87
|
if (weight === undefined) return DEFAULT_PROXY_WEIGHT;
|
|
72
88
|
if (!Number.isInteger(weight) || weight < 1 || weight > MAX_PROXY_WEIGHT) return 0;
|
|
@@ -257,6 +273,20 @@ export class NetworkPoolSelector {
|
|
|
257
273
|
return `${poolId}::${providerId.toLowerCase()}`;
|
|
258
274
|
}
|
|
259
275
|
|
|
276
|
+
/**
|
|
277
|
+
* Seeds one cooldown entry into the local map without touching Redis.
|
|
278
|
+
*
|
|
279
|
+
* Boot restore is the only writer: it replays the durable markers the
|
|
280
|
+
* previous process recorded (`network_pools.provider_cooldowns`, see
|
|
281
|
+
* `pool-cooldown-store.ts`) so dispatch starts remembering the egresses the
|
|
282
|
+
* upstream is still refusing. Anything whose window already passed is
|
|
283
|
+
* dropped by `restore…` before this is reached.
|
|
284
|
+
*/
|
|
285
|
+
hydrateProviderCooldown(entry: ProxyCooldownEntry): void {
|
|
286
|
+
enforceCooldownLimit(this.state.cooldowns);
|
|
287
|
+
this.state.cooldowns.set(this.cooldownKey(entry.poolId, entry.providerId), entry);
|
|
288
|
+
}
|
|
289
|
+
|
|
260
290
|
async flagProviderCooldown(
|
|
261
291
|
poolId: string,
|
|
262
292
|
providerId: string,
|
|
@@ -308,12 +338,32 @@ export class NetworkPoolSelector {
|
|
|
308
338
|
* The entry uses the same storage as a cooldown so every reader that already
|
|
309
339
|
* skips a cooling pool skips this one too; `reason` is what the console
|
|
310
340
|
* renders to tell an operator-blocked proxy from a throttled one.
|
|
341
|
+
*
|
|
342
|
+
* Refuses to retire an egress for a provider whose limits belong to a
|
|
343
|
+
* credential. An address-scoped verdict only exists for providers that count
|
|
344
|
+
* requests by egress (`ipScopedRateLimit` — opencode); for everyone else a
|
|
345
|
+
* refusal belongs to the account that was dialed, so retiring the pool would
|
|
346
|
+
* sideline an address that is still serving every sibling account. Callers
|
|
347
|
+
* keep their own guard, and this one is the backstop that makes the rule
|
|
348
|
+
* impossible to route around from a new call site.
|
|
311
349
|
*/
|
|
312
350
|
async flagProviderBlocked(
|
|
313
351
|
poolId: string,
|
|
314
352
|
providerId: string,
|
|
315
353
|
reason = "Upstream refused this egress address",
|
|
316
354
|
): Promise<ProxyCooldownEntry> {
|
|
355
|
+
if (!providerRateLimitIsIpScoped(providerId)) {
|
|
356
|
+
log.warn("[pool-selector] refused to block an egress for a non-IP-scoped provider", {
|
|
357
|
+
networkPoolId: poolId,
|
|
358
|
+
providerId,
|
|
359
|
+
});
|
|
360
|
+
return {
|
|
361
|
+
poolId,
|
|
362
|
+
providerId: providerId.toLowerCase(),
|
|
363
|
+
until: Date.now(),
|
|
364
|
+
reason,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
317
367
|
const entry = await this.flagProviderCooldown(poolId, providerId, PROVIDER_BLOCK_MS, reason);
|
|
318
368
|
const blocked: ProxyCooldownEntry = { ...entry, blocked: true };
|
|
319
369
|
// Rewrite both copies with the flag: the cooldown write above is shared
|
|
@@ -455,6 +505,38 @@ export class NetworkPoolSelector {
|
|
|
455
505
|
await this.clearRedisCooldown(poolId, providerId);
|
|
456
506
|
}
|
|
457
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Releases a *retired* egress only, leaving a cooldown intact.
|
|
510
|
+
*
|
|
511
|
+
* The two are one key, so `clearProviderCooldown` cannot be reused: it would
|
|
512
|
+
* also drop a live 429 cooldown and hand a spent address back to dispatch.
|
|
513
|
+
* This is the correction a 429 probe is entitled to make — the address is
|
|
514
|
+
* answering its own rate limiter, so it was never retired — and nothing more.
|
|
515
|
+
*
|
|
516
|
+
* No-op when the marker is a cooldown, and no-op when there is no marker.
|
|
517
|
+
*/
|
|
518
|
+
async clearProviderBlockedOnly(poolId: string, providerId: string): Promise<void> {
|
|
519
|
+
const key = this.cooldownKey(poolId, providerId);
|
|
520
|
+
const local = this.state.cooldowns.get(key);
|
|
521
|
+
if (local !== undefined) {
|
|
522
|
+
if (local.blocked !== true) return;
|
|
523
|
+
this.state.cooldowns.delete(key);
|
|
524
|
+
}
|
|
525
|
+
if (!this.redis) return;
|
|
526
|
+
try {
|
|
527
|
+
const raw = await this.redis.get(proxyCooldownKey(poolId, providerId));
|
|
528
|
+
const entry = raw ? parseCooldownEntry(raw) : undefined;
|
|
529
|
+
// A missing or unparseable marker says nothing about the address, and
|
|
530
|
+
// deleting a key we cannot read would drop a cooldown this is forbidden
|
|
531
|
+
// from touching. Only a marker that names itself a block is released.
|
|
532
|
+
if (entry?.blocked !== true) return;
|
|
533
|
+
await this.clearRedisCooldown(poolId, providerId);
|
|
534
|
+
} catch {
|
|
535
|
+
// The local copy is already corrected; the Redis mirror stays until its
|
|
536
|
+
// TTL or the next write.
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
458
540
|
/**
|
|
459
541
|
* Removes one cooldown marker and its index entry in one atomic step.
|
|
460
542
|
*
|
|
@@ -564,6 +646,26 @@ export class NetworkPoolSelector {
|
|
|
564
646
|
if (scored.length === 0) return undefined;
|
|
565
647
|
|
|
566
648
|
if (!this.redis) {
|
|
649
|
+
if (rotation?.strategy === "random") {
|
|
650
|
+
// Random scatter: shuffle the eligible pools and take the first one
|
|
651
|
+
// with a free slot, falling through like the ordered scans do.
|
|
652
|
+
shuffleArray(scored);
|
|
653
|
+
for (const candidate of scored) {
|
|
654
|
+
if (this.getInflight(candidate.poolId) >= candidate.capacity) continue;
|
|
655
|
+
const slot =
|
|
656
|
+
providerCapacity === undefined
|
|
657
|
+
? this.acquire(candidate.poolId, candidate.capacity)
|
|
658
|
+
: this.acquireProvider(
|
|
659
|
+
candidate.poolId,
|
|
660
|
+
providerId,
|
|
661
|
+
candidate.capacity,
|
|
662
|
+
providerCapacity,
|
|
663
|
+
);
|
|
664
|
+
if (!slot.acquired) continue;
|
|
665
|
+
return { poolId: candidate.poolId, release: slot.release };
|
|
666
|
+
}
|
|
667
|
+
return undefined;
|
|
668
|
+
}
|
|
567
669
|
if (rotation) {
|
|
568
670
|
// Round robin: scan from the tenant cursor; a pool that is full falls
|
|
569
671
|
// through to the next offset (failover). Only a successful admission
|
|
@@ -662,7 +764,13 @@ export class NetworkPoolSelector {
|
|
|
662
764
|
// Original (pre-sort) index = absolute rotation position and
|
|
663
765
|
// deterministic tie-breaking.
|
|
664
766
|
const absoluteIndex = new Map(withCounts.map((candidate, index) => [candidate.poolId, index]));
|
|
665
|
-
if (rotation) {
|
|
767
|
+
if (rotation?.strategy === "random") {
|
|
768
|
+
// Random scatter: shuffle before the admit loop, which still enforces
|
|
769
|
+
// capacity and fails over to the next pool exactly like the ordered
|
|
770
|
+
// scans — but the order each request tries is fresh, so a burst does
|
|
771
|
+
// not gang up on the first pools in a fixed order.
|
|
772
|
+
shuffleArray(withCounts);
|
|
773
|
+
} else if (rotation) {
|
|
666
774
|
// Round robin orders strictly by the tenant cursor; the CAS admit below
|
|
667
775
|
// still enforces capacity, so a full pool falls through to the next
|
|
668
776
|
// offset exactly like account failover.
|
|
@@ -715,7 +823,7 @@ export class NetworkPoolSelector {
|
|
|
715
823
|
// to the next pool, which is the whole point of the ceiling: the request
|
|
716
824
|
// moves to an egress that still has room for this provider.
|
|
717
825
|
if (result !== 1) continue;
|
|
718
|
-
if (rotation) {
|
|
826
|
+
if (rotation && rotation.strategy !== "random") {
|
|
719
827
|
advanceRotation(
|
|
720
828
|
this.state.rotationCursors,
|
|
721
829
|
rotation.key,
|