cartethyia 2.6.7 → 2.7.1
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 +1 -7
- package/README.md +25 -2
- package/dist/dashboard/assets/{BannedPage-Dskhyp0l.js → BannedPage-D-J75hBM.js} +1 -1
- package/dist/dashboard/assets/BannedPage-D-J75hBM.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage--SJhk7qQ.js → CliToolDetailPage-CVe6RaBH.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-CVe6RaBH.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-C203VT-c.js → CliToolsPage-Dd8ZSamp.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-Dd8ZSamp.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BcS7kGG1.js → CombosPage-QoJ6foxo.js} +1 -1
- package/dist/dashboard/assets/CombosPage-QoJ6foxo.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js +1 -0
- package/dist/dashboard/assets/ConsoleLogPage-DpNRay4C.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-uujbBYwC.js → CustomizationPage-CKUFTcW4.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-CKUFTcW4.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js +1 -0
- package/dist/dashboard/assets/HealthEventsModal-CzSLiDzr.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js +1 -0
- package/dist/dashboard/assets/LoginPage-DubdQcvX.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-CA1kLs45.js → ModelPicker-BH8Tehdi.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js +1 -0
- package/dist/dashboard/assets/OverviewPage-DuWzAQFL.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.js +4 -0
- package/dist/dashboard/assets/ProviderDetailPage-BKEKe2Fm.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-Bwotu8UJ.js +1 -0
- package/dist/dashboard/assets/ProvidersPage-Bwotu8UJ.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js +9 -0
- package/dist/dashboard/assets/ProxyPage-D7hpWbg9.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BueSWX5D.js → QuotaPage-ATpaxrbl.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-ATpaxrbl.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js +1 -0
- package/dist/dashboard/assets/SettingsPage-CETophpx.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js +1 -0
- package/dist/dashboard/assets/SetupPage-BdgECjqq.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-DrKeMcGN.js → SortableList-CAK-EN9z.js} +1 -1
- package/dist/dashboard/assets/SortableList-CAK-EN9z.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-qjuYtccc.js → StudioPage-B7W2_RhT.js} +3 -3
- package/dist/dashboard/assets/StudioPage-B7W2_RhT.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js +56 -0
- package/dist/dashboard/assets/UsagePage-6T4EFDjW.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-C23S9sgp.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-DU6qrMA_.js → assert-67xXH5rf.js} +1 -1
- package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
- package/dist/dashboard/assets/{badge-Bkz_-RrM.js → badge-DC2L43Mj.js} +1 -1
- package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
- package/dist/dashboard/assets/{banner-C-7y3VKV.js → banner-BBVcb0J3.js} +1 -1
- package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
- package/dist/dashboard/assets/{card-BOPBuBzD.js → card-Cca1XC6G.js} +1 -1
- package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-BaFUuFFF.js → cli-tools-KvsWa1Jl.js} +1 -1
- package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-e-Mlk0FH.js → clipboard-button-DUzEbynq.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-Iec_ywbD.js → dialog-DFvWhwTa.js} +1 -1
- package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-Bs8MIIHc.js → drawer-CFM2sMX1.js} +1 -1
- package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DOddwoc0.js → entry-BLBLxX0h.js} +3 -3
- package/dist/dashboard/assets/entry-BLBLxX0h.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DcobaKFu.js → entry-Bqy8RFvM.js} +1 -1
- package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BRszm4_Z.js → entry-_xHo5x6r.js} +1 -1
- package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js +1 -0
- package/dist/dashboard/assets/formatters-mpBPqAQ8.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BuDsrsPn.js → github-badge-C9e8Ts7e.js} +1 -1
- package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
- package/dist/dashboard/assets/{index-BeLRhsmt.js → index-D6QzEC_n.js} +2 -2
- package/dist/dashboard/assets/index-D6QzEC_n.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DToMICwl.js → inline-BFUcvllj.js} +1 -1
- package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
- package/dist/dashboard/assets/{input-WlyHWeG2.js → input-C_ILAvBb.js} +1 -1
- package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
- package/dist/dashboard/assets/{layout-DaJ5baJr.js → layout-CBIyT6Rt.js} +1 -1
- package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-B3oSK13z.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-ta6GMb-8.js → live-D0mm--ME.js} +1 -1
- package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +1 -0
- package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-g_fduiNw.js → mutation-sMD965oO.js} +1 -1
- package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-Dulqgys5.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-UETqfytp.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-BPUo-W8d.js → routing-BnIpi1IJ.js} +1 -1
- package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
- package/dist/dashboard/assets/{select-BfZ3SCTM.js → select-Cj5Nw43g.js} +1 -1
- package/dist/dashboard/assets/select-Cj5Nw43g.js.gz +0 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js +1 -0
- package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
- package/dist/dashboard/assets/{stack-B1w5BM0u.js → stack-EMrOMt4I.js} +1 -1
- package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
- package/dist/dashboard/assets/{state-DhZBfWgx.js → state-CWoo7ty5.js} +1 -1
- package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
- package/dist/dashboard/assets/{switch-yD7D99Lg.js → switch-CCjT6IkF.js} +1 -1
- package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-EDFRkn1i.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 +55 -54
- 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 +1 -1
- package/scripts/cli/console-menu.ts +5 -2
- package/scripts/cli/tray.ts +21 -6
- package/src/boot/app.ts +24 -62
- package/src/boot/dependencies.ts +732 -750
- package/src/config/cooldowns.ts +16 -0
- package/src/config/providers.ts +13 -1
- package/src/config/resources.ts +13 -80
- package/src/config/security.ts +2 -38
- package/src/config/spec.ts +16 -23
- package/src/console/console-router.ts +7 -7
- package/src/console/domain-registration.ts +19 -12
- 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/remote-support/contracts.ts +3 -1
- package/src/console/remote-support/service.ts +115 -6
- 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 +16 -2
- package/src/network/outbound-fetch.ts +226 -365
- package/src/network/pool/agent.ts +8 -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 +15 -5
- package/src/observability/runtime-metrics.ts +30 -15
- 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 +0 -1
- 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 +21 -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/commandcode/commandcode.ts +134 -104
- package/src/providers/integrations/commandcode/discovery.ts +6 -5
- 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/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.ts +1641 -884
- 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 +1660 -0
- package/src/providers/operations/account-health-service.ts +621 -2097
- package/src/providers/operations/account-reset-service.ts +2 -0
- package/src/providers/provider-contracts.ts +0 -2
- 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/abuse.ts +17 -0
- 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 +224 -217
- 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 +59 -178
- package/src/transport/guards/abuse-guard.ts +10 -0
- package/src/transport/guards/auth-guard.ts +0 -18
- package/src/transport/guards/error-lifecycle.ts +1 -1
- package/src/transport/guards/pipeline.ts +0 -6
- package/src/transport/guards/request-context.ts +18 -91
- package/src/transport/request/degradation.ts +20 -1
- package/src/transport/request/inflight.ts +4 -95
- 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 +41 -31
- package/src/transport/routing/router.ts +2 -2
- package/src/transport/surface/messages/parse.ts +1 -1
- 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-Dskhyp0l.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage--SJhk7qQ.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-C203VT-c.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BcS7kGG1.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js +0 -1
- package/dist/dashboard/assets/ConsoleLogPage-ChmiSLwO.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-uujbBYwC.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js +0 -1
- package/dist/dashboard/assets/HealthEventsModal-fKAWd-OQ.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js +0 -1
- package/dist/dashboard/assets/LoginPage-DoOUthbG.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-CA1kLs45.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js +0 -1
- package/dist/dashboard/assets/OverviewPage-C5Z-UBz1.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-CFu1qGcT.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-CnlJZbxF.js +0 -1
- package/dist/dashboard/assets/ProvidersPage-CnlJZbxF.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js +0 -7
- package/dist/dashboard/assets/ProxyPage-B9mBTweh.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BueSWX5D.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js +0 -1
- package/dist/dashboard/assets/SettingsPage-B0x00MVY.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js +0 -1
- package/dist/dashboard/assets/SetupPage-DQ9oDRpi.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-DrKeMcGN.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-qjuYtccc.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js +0 -56
- package/dist/dashboard/assets/UsagePage-Be7aNviQ.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
- package/dist/dashboard/assets/app-BUSiYL6F.css +0 -2
- package/dist/dashboard/assets/app-BUSiYL6F.css.gz +0 -0
- package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
- package/dist/dashboard/assets/badge-Bkz_-RrM.js.gz +0 -0
- package/dist/dashboard/assets/banner-C-7y3VKV.js.gz +0 -0
- package/dist/dashboard/assets/card-BOPBuBzD.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-e-Mlk0FH.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-Iec_ywbD.js.gz +0 -0
- package/dist/dashboard/assets/drawer-Bs8MIIHc.js.gz +0 -0
- package/dist/dashboard/assets/entry-BRszm4_Z.js.gz +0 -0
- package/dist/dashboard/assets/entry-DOddwoc0.js.gz +0 -0
- package/dist/dashboard/assets/entry-DcobaKFu.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BuDsrsPn.js.gz +0 -0
- package/dist/dashboard/assets/index-BeLRhsmt.js.gz +0 -0
- package/dist/dashboard/assets/inline-DToMICwl.js.gz +0 -0
- package/dist/dashboard/assets/input-WlyHWeG2.js.gz +0 -0
- package/dist/dashboard/assets/layout-DaJ5baJr.js.gz +0 -0
- package/dist/dashboard/assets/list-row-B3oSK13z.js.gz +0 -0
- package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js +0 -1
- package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
- package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
- package/dist/dashboard/assets/numbers-Dulqgys5.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
- package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
- package/dist/dashboard/assets/select-BfZ3SCTM.js.gz +0 -0
- package/dist/dashboard/assets/settings--3hxSyry.js +0 -1
- package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
- package/dist/dashboard/assets/stack-B1w5BM0u.js.gz +0 -0
- package/dist/dashboard/assets/state-DhZBfWgx.js.gz +0 -0
- package/dist/dashboard/assets/switch-yD7D99Lg.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
- package/src/console/admin/model-abuse/contracts.ts +0 -38
- package/src/console/admin/model-abuse/routes.ts +0 -97
- package/src/security/model-abuse.ts +0 -704
- package/src/transport/guards/concurrency-guard.ts +0 -125
- /package/dist/dashboard/assets/{app-MdVfwraB.js → app-BaWd0puM.js} +0 -0
- /package/dist/dashboard/assets/{app-MdVfwraB.js.gz → app-BaWd0puM.js.gz} +0 -0
|
@@ -1,184 +1,26 @@
|
|
|
1
1
|
import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "../protocol/gateway-error";
|
|
2
2
|
import { isRecord } from "../protocol/codecs";
|
|
3
3
|
import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
|
|
4
|
+
import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
|
|
4
5
|
|
|
5
|
-
const MAX_UPSTREAM_ERROR_BYTES = 500;
|
|
6
|
-
const MAX_COOLDOWN_MS = 24 * 3600 * 1000;
|
|
7
|
-
|
|
8
|
-
/** Parses Retry-After seconds or an HTTP date, bounded to one day. */
|
|
9
|
-
export function parseRetryAfter(value: string | null | undefined): number | null {
|
|
10
|
-
if (!value) return null;
|
|
11
|
-
const trimmed = value.trim();
|
|
12
|
-
const numeric = Number(trimmed);
|
|
13
|
-
if (Number.isFinite(numeric) && numeric > 0) return Math.min(MAX_COOLDOWN_MS, numeric * 1000);
|
|
14
|
-
const parsed = Date.parse(trimmed);
|
|
15
|
-
if (!Number.isFinite(parsed)) return null;
|
|
16
|
-
const delay = parsed - Date.now();
|
|
17
|
-
return delay > 0 ? Math.min(MAX_COOLDOWN_MS, delay) : null;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Parses x-ratelimit-reset as epoch seconds or a relative delay.
|
|
22
|
-
*
|
|
23
|
-
* Returns `null` — not `0` — when the instant has already passed. This matters
|
|
24
|
-
* because `parseUpstreamBackoff` chains these parsers with `??`, which falls
|
|
25
|
-
* through only on null: a `0` from an elapsed `x-ratelimit-reset` (a cached error
|
|
26
|
-
* body, clock skew, a stale edge response) stopped the chain before the
|
|
27
|
-
* lower-priority headers that carried the real wait. The `0` was then written to
|
|
28
|
-
* `details.retryAfterMs` and read by `classifyAccountError` as `headerCooldown`,
|
|
29
|
-
* which outranks the provider's own message text — so both were discarded and the
|
|
30
|
-
* account fell back to the generic default cooldown while the provider had parked
|
|
31
|
-
* it for the rest of the window.
|
|
32
|
-
*
|
|
33
|
-
* `parseRetryAfter` above already returns `null` for an elapsed date; matching it
|
|
34
|
-
* is what removes the asymmetry.
|
|
35
|
-
*/
|
|
36
|
-
export function parseRateLimitReset(value: string | null | undefined): number | null {
|
|
37
|
-
if (!value) return null;
|
|
38
|
-
const numeric = Number(value.trim());
|
|
39
|
-
if (!Number.isFinite(numeric) || numeric <= 0) return null;
|
|
40
|
-
const target = numeric >= 1_000_000_000 ? numeric * 1000 : Date.now() + numeric * 1000;
|
|
41
|
-
const remaining = target - Date.now();
|
|
42
|
-
return remaining > 0 ? Math.min(MAX_COOLDOWN_MS, remaining) : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** Parses a millisecond-denominated reset header (`retry-after-ms`, `x-ratelimit-reset-ms`). */
|
|
46
|
-
export function parseResetAfterMs(value: string | null | undefined): number | null {
|
|
47
|
-
if (!value) return null;
|
|
48
|
-
const numeric = Number(value.trim());
|
|
49
|
-
if (!Number.isFinite(numeric) || numeric <= 0) return null;
|
|
50
|
-
return Math.min(MAX_COOLDOWN_MS, Math.round(numeric));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Reads upstream backoff evidence in reference priority order: millisecond
|
|
55
|
-
* variants first (`retry-after-ms`, then `x-ratelimit-reset-ms`), then the
|
|
56
|
-
* classic `retry-after` / `x-ratelimit-reset` pair.
|
|
57
|
-
*/
|
|
58
|
-
export function parseUpstreamBackoff(headers: {
|
|
59
|
-
get(name: string): string | null;
|
|
60
|
-
}): number | null {
|
|
61
|
-
return (
|
|
62
|
-
parseResetAfterMs(headers.get("retry-after-ms")) ??
|
|
63
|
-
parseRetryAfter(headers.get("retry-after")) ??
|
|
64
|
-
parseResetAfterMs(headers.get("x-ratelimit-reset-ms")) ??
|
|
65
|
-
parseRateLimitReset(headers.get("x-ratelimit-reset")) ??
|
|
66
|
-
parseRateLimitReset(headers.get("x-ratelimit-reset-requests")) ??
|
|
67
|
-
parseRateLimitReset(headers.get("retry-after"))
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Extracts bounded reset durations from provider messages when headers omit
|
|
73
|
-
* them.
|
|
74
|
-
*
|
|
75
|
-
* Two shapes are recognized, because providers state resets both ways:
|
|
76
|
-
*
|
|
77
|
-
* - **Relative** — "quota will reset in 3 hours", "try again in 30s".
|
|
78
|
-
* - **Absolute** — "your usage will reset at 2026-09-24 02:12:51 UTC+8"
|
|
79
|
-
* (WorkBuddy/CodeBuddy `6004`). An absolute stamp is *not* a duration, and
|
|
80
|
-
* the relative-only pattern silently missed it, so the account fell back to
|
|
81
|
-
* the generic 15-minute default while the provider had actually parked it
|
|
82
|
-
* for the rest of the window — the account kept re-entering rotation and
|
|
83
|
-
* failing every request inside the stated reset window.
|
|
84
|
-
*/
|
|
85
6
|
/**
|
|
86
|
-
*
|
|
7
|
+
* How much of the upstream's error body is kept and surfaced.
|
|
87
8
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
9
|
+
* 500 bytes cut the CodeBuddy envelope mid-`extError`: the operator saw
|
|
10
|
+
* "request illegal" with none of the `displayMsg` prose that names the actual
|
|
11
|
+
* condition — "prompt is too long: 1048788 tokens > 1048576 maximum" — so a
|
|
12
|
+
* context overflow was indistinguishable from a policy refusal. The envelope
|
|
13
|
+
* runs a few KB; 4000 keeps the whole thing while bounding a hostile body.
|
|
93
14
|
*/
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
*
|
|
104
|
-
* Providers state a reset as one compound duration at least as often as a
|
|
105
|
-
* single unit: a daily-limit 429 reads "Try again in 4h 13m". A single-pair
|
|
106
|
-
* parser read only `4h` and dropped the 13 minutes, so the stored retry
|
|
107
|
-
* deadline was earlier than the window the provider had actually stated and the
|
|
108
|
-
* account re-entered rotation before it. Pairs are accepted only while they
|
|
109
|
-
* continue the phrase — separated by whitespace, a comma, or `and` — so a
|
|
110
|
-
* number that merely follows later in the sentence cannot be absorbed.
|
|
111
|
-
*/
|
|
112
|
-
function parseCompoundDuration(text: string): number | null {
|
|
113
|
-
const pairs = new RegExp(DURATION_PAIR_SOURCE, "gi");
|
|
114
|
-
let totalMs = 0;
|
|
115
|
-
let lastEnd = 0;
|
|
116
|
-
let matched = false;
|
|
117
|
-
for (let pair = pairs.exec(text); pair !== null; pair = pairs.exec(text)) {
|
|
118
|
-
const gap = text.slice(lastEnd, pair.index);
|
|
119
|
-
const continues = matched ? /^[\s,]*(?:and\s+)?$/i.test(gap) : /^\s*$/.test(gap);
|
|
120
|
-
if (!continues) break;
|
|
121
|
-
const amount = Number(pair[1]);
|
|
122
|
-
if (!Number.isFinite(amount) || amount <= 0) break;
|
|
123
|
-
totalMs += amount * durationUnitMs(pair[2] ?? "");
|
|
124
|
-
matched = true;
|
|
125
|
-
lastEnd = pair.index + pair[0].length;
|
|
126
|
-
}
|
|
127
|
-
return matched ? Math.round(totalMs) : null;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export function parseProviderResetDuration(message: string): number | null {
|
|
131
|
-
const trigger =
|
|
132
|
-
/(?:quota will reset|resets?|try again|retry(?:ing)?)\s+(?:in|after|over)\s+(?:a\s+)?(?:rolling\s+)?/i.exec(
|
|
133
|
-
message,
|
|
134
|
-
);
|
|
135
|
-
if (trigger) {
|
|
136
|
-
const parsed = parseCompoundDuration(message.slice(trigger.index + trigger[0].length));
|
|
137
|
-
if (parsed !== null) return Math.min(MAX_COOLDOWN_MS, parsed);
|
|
138
|
-
}
|
|
139
|
-
return parseAbsoluteResetTimestamp(message);
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Reads an absolute reset instant out of a provider message, e.g.
|
|
144
|
-
* `will reset at 2026-09-24 02:12:51 UTC+8`. Returns the delay from now,
|
|
145
|
-
* bounded to one day, or null when no future timestamp is present.
|
|
146
|
-
*
|
|
147
|
-
* `Date.parse` cannot read the space-separated `YYYY-MM-DD HH:MM:SS` form with
|
|
148
|
-
* a bare `UTC±H` offset, so the parts are normalized explicitly rather than
|
|
149
|
-
* relying on engine-specific leniency.
|
|
150
|
-
*/
|
|
151
|
-
export function parseAbsoluteResetTimestamp(message: string): number | null {
|
|
152
|
-
const match = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*(?:UTC|GMT)?\s*([+-]\d{1,2})?(?::?(\d{2}))?/i.exec(
|
|
153
|
-
message,
|
|
154
|
-
);
|
|
155
|
-
if (!match) return null;
|
|
156
|
-
const [, year, month, day, hour, minute, second, offsetHours, offsetMinutes] = match;
|
|
157
|
-
if (!year || !month || !day || !hour || !minute || !second) return null;
|
|
158
|
-
// A bare `UTC+8` means a whole-hour offset; `+08:30`-style halves are rare
|
|
159
|
-
// but cheap to honor. No offset at all is read as UTC (providers that omit
|
|
160
|
-
// it always label the stamp "UTC" in practice).
|
|
161
|
-
const sign = offsetHours?.startsWith("-") ? -1 : 1;
|
|
162
|
-
const offsetTotalMinutes =
|
|
163
|
-
offsetHours === undefined
|
|
164
|
-
? 0
|
|
165
|
-
: sign * (Math.abs(Number(offsetHours)) * 60 + Number(offsetMinutes ?? 0));
|
|
166
|
-
const utcMs = Date.UTC(
|
|
167
|
-
Number(year),
|
|
168
|
-
Number(month) - 1,
|
|
169
|
-
Number(day),
|
|
170
|
-
Number(hour),
|
|
171
|
-
Number(minute),
|
|
172
|
-
Number(second),
|
|
173
|
-
);
|
|
174
|
-
if (!Number.isFinite(utcMs)) return null;
|
|
175
|
-
const targetMs = utcMs - offsetTotalMinutes * 60_000;
|
|
176
|
-
const delay = targetMs - Date.now();
|
|
177
|
-
// A past stamp is not a usable reset (clock skew, stale cached error); fall
|
|
178
|
-
// through to the caller's default instead of returning a negative delay.
|
|
179
|
-
if (!Number.isFinite(delay) || delay <= 0) return null;
|
|
180
|
-
return Math.min(MAX_COOLDOWN_MS, Math.round(delay));
|
|
181
|
-
}
|
|
15
|
+
const MAX_UPSTREAM_ERROR_BYTES = 4000;
|
|
16
|
+
export {
|
|
17
|
+
parseRetryAfter,
|
|
18
|
+
parseRateLimitReset,
|
|
19
|
+
parseResetAfterMs,
|
|
20
|
+
parseUpstreamBackoff,
|
|
21
|
+
parseProviderResetDuration,
|
|
22
|
+
parseAbsoluteResetTimestamp,
|
|
23
|
+
} from "../protocol/upstream-parsing";
|
|
182
24
|
/** Extracts a bounded, newline-free provider message from any error envelope. */
|
|
183
25
|
export function extractUpstreamMessage(body: unknown): string {
|
|
184
26
|
let raw = "";
|
|
@@ -544,7 +386,27 @@ export interface TerminalOutcome {
|
|
|
544
386
|
readonly status: "cancelled" | "failed";
|
|
545
387
|
readonly errorCategory: GatewayErrorCode | "unknown_error";
|
|
546
388
|
readonly errorOrigin: GatewayErrorOrigin;
|
|
547
|
-
|
|
389
|
+
/**
|
|
390
|
+
* The HTTP status the row should carry. `undefined` means the caller has a
|
|
391
|
+
* better one (the wire status of an actual response) and should keep it;
|
|
392
|
+
* a raw error has none, and the caller's own fallback was 500 — which
|
|
393
|
+
* read as a gateway fault for what was an upstream transport drop.
|
|
394
|
+
*/
|
|
395
|
+
readonly httpStatus?: number;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The HTTP status each classifier-derived category carries when no wire response named one. */
|
|
399
|
+
const CATEGORY_HTTP_STATUS: Partial<Record<GatewayErrorCode | "unknown_error", number>> = {
|
|
400
|
+
// The upstream's transport died mid-response: a bad gateway, not a fault
|
|
401
|
+
// in this process.
|
|
402
|
+
transport_unavailable: 502,
|
|
403
|
+
platform_unavailable: 502,
|
|
404
|
+
deadline_exceeded: 504,
|
|
405
|
+
transport_closed: 499,
|
|
406
|
+
shutting_down: 503,
|
|
407
|
+
overloaded: 529,
|
|
408
|
+
unknown_error: 500,
|
|
409
|
+
};
|
|
548
410
|
|
|
549
411
|
/**
|
|
550
412
|
* Derives a failed attempt's status, error category, and error origin **from
|
|
@@ -559,11 +421,24 @@ export interface TerminalOutcome {
|
|
|
559
421
|
* outcome carries the real code and origin.
|
|
560
422
|
*/
|
|
561
423
|
export function classifyTerminalOutcome(error: unknown, signal: AbortSignal): TerminalOutcome {
|
|
424
|
+
const category = classifyTerminalCategory(error, signal);
|
|
425
|
+
const gateway = error instanceof GatewayError ? error : signal.reason instanceof GatewayError ? signal.reason : undefined;
|
|
562
426
|
return {
|
|
563
427
|
status: isClientCancellation(error, signal) ? "cancelled" : "failed",
|
|
564
|
-
errorCategory:
|
|
565
|
-
//
|
|
566
|
-
|
|
428
|
+
errorCategory: category,
|
|
429
|
+
// The classifier owns the blame. An upstream socket drop arrives as a
|
|
430
|
+
// raw runtime error — no `GatewayError` wraps it — and calling that ours
|
|
431
|
+
// made the row accuse the gateway for an upstream failure.
|
|
432
|
+
errorOrigin: gateway
|
|
433
|
+
? gateway.origin
|
|
434
|
+
: category === "transport_unavailable"
|
|
435
|
+
? "upstream"
|
|
436
|
+
: "cartethyia",
|
|
437
|
+
// A `GatewayError` carries its own status; a raw error takes the
|
|
438
|
+
// category's, so an upstream drop is 502 rather than the 500 fallback.
|
|
439
|
+
...(gateway || CATEGORY_HTTP_STATUS[category] !== undefined
|
|
440
|
+
? { httpStatus: gateway ? gateway.status : CATEGORY_HTTP_STATUS[category] }
|
|
441
|
+
: {}),
|
|
567
442
|
};
|
|
568
443
|
}
|
|
569
444
|
|
|
@@ -735,6 +610,12 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
|
|
|
735
610
|
"usage_not_included",
|
|
736
611
|
"subscription:free-usage-exhausted",
|
|
737
612
|
"resource_exhausted",
|
|
613
|
+
// Qoder answers an exhausted daily billing allowance with a bare numeric
|
|
614
|
+
// code: `{"code":"110","message":"Billing daily count exceeded"}`. The 403
|
|
615
|
+
// wrapper made the old status-only fallback read it as a credential fault;
|
|
616
|
+
// it is a spend/limit verdict, never an auth one, so it must classify as
|
|
617
|
+
// quota (a cooldown) instead of `auth_invalidated`.
|
|
618
|
+
"110",
|
|
738
619
|
// InferHub publishes its own vocabulary in `error.code`. The two 403s below
|
|
739
620
|
// are deliberate absences: `account_blocked` means the account was blocked
|
|
740
621
|
// (an auth/entitlement verdict, not a spend limit) and
|
|
@@ -69,6 +69,16 @@ export function createIpAbuseProtectionMiddleware(deps: {
|
|
|
69
69
|
throw peerAddressUnavailableError();
|
|
70
70
|
const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
|
|
71
71
|
const state = deps.stateStore.get(request);
|
|
72
|
+
// The identity middleware runs later in the plugin chain and resolves the
|
|
73
|
+
// same value from the same boundary; stashing it here means the request
|
|
74
|
+
// pays for one resolveClientIdentity instead of two, and the middleware can
|
|
75
|
+
// skip its own when it finds the stash.
|
|
76
|
+
if (state !== undefined) {
|
|
77
|
+
state.clientIdentity ??= {
|
|
78
|
+
address,
|
|
79
|
+
source: address === peer ? "tcp-peer" : "trusted-forwarded-header",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
72
82
|
try {
|
|
73
83
|
await deps.ipAbuseProtection.checkBeforeAccess({
|
|
74
84
|
identity: {
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
detectClientRouter,
|
|
13
13
|
} from "../../security/client-router-fingerprint";
|
|
14
14
|
import { createAccessDecision } from "../../security/access-control";
|
|
15
|
-
import { modelAbuseBannedError, type ModelStrikeService } from "../../security/model-abuse";
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
17
|
* Stateless Elysia `beforeHandle` gateway check: API-key authentication,
|
|
@@ -27,7 +26,6 @@ export function createApiKeyAuthenticationMiddleware(deps: {
|
|
|
27
26
|
* assigned — so a banned caller's attempts produce no telemetry row and no
|
|
28
27
|
* console error, which is exactly the noise this layer exists to stop.
|
|
29
28
|
*/
|
|
30
|
-
readonly modelStrikes?: Pick<ModelStrikeService, "check">;
|
|
31
29
|
/**
|
|
32
30
|
* Tenant invoke kill switch. Checked after the key resolves, because the
|
|
33
31
|
* tenant is a property of the key: the same gateway serves several tenants
|
|
@@ -105,22 +103,6 @@ export function createApiKeyAuthenticationMiddleware(deps: {
|
|
|
105
103
|
"No API invocation access for this client.",
|
|
106
104
|
{ reason: "client_router_denied", clientRouter: denied },
|
|
107
105
|
);
|
|
108
|
-
// Abuse ban: refuse before parse/prepare and before authorization is
|
|
109
|
-
// recorded, so a banned caller cannot keep producing failed rows. The ban
|
|
110
|
-
// is keyed on the client address *and* the API key: one key can be shared
|
|
111
|
-
// by every recipient of a share link, so refusing the address alone would
|
|
112
|
-
// punish callers that did nothing, while refusing the key alone would let
|
|
113
|
-
// the caller that probed mint a fresh one.
|
|
114
|
-
if (deps.modelStrikes) {
|
|
115
|
-
const state = deps.stateStore.get(request);
|
|
116
|
-
const ip = state?.clientIdentity?.address;
|
|
117
|
-
if (ip !== undefined) {
|
|
118
|
-
const banned = await deps.modelStrikes
|
|
119
|
-
.check({ ip, apiKeyId: authorization.id })
|
|
120
|
-
.catch(() => false);
|
|
121
|
-
if (banned) throw modelAbuseBannedError();
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
106
|
deps.stateStore.require(request).authorization = authorization;
|
|
125
107
|
})
|
|
126
108
|
.as("plugin");
|
|
@@ -7,7 +7,7 @@ import type { TelemetryBatchBuffer, TelemetryEventInput } from "../../observabil
|
|
|
7
7
|
import type { TelemetryPayloadCapture } from "../../observability/payload-capture";
|
|
8
8
|
import { captureEarlyRejectionPayload } from "./early-rejection-capture";
|
|
9
9
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
10
|
-
import { reasoningEffortFromIntent } from "../../
|
|
10
|
+
import { reasoningEffortFromIntent } from "../../protocol/reasoning";
|
|
11
11
|
import { metrics } from "../../observability/metrics";
|
|
12
12
|
import { computeTokensPerSec } from "../../observability/token-speed";
|
|
13
13
|
import { pushStructuredConsoleLog } from "../../observability/log-ring";
|
|
@@ -10,7 +10,6 @@ import type { CanonicalAdapter, ShutdownDrainSource } from "../contracts";
|
|
|
10
10
|
import { createApiKeyAuthenticationMiddleware } from "./auth-guard";
|
|
11
11
|
import { createDependencyReadinessMiddleware } from "./readiness-guard";
|
|
12
12
|
import { createIpAbuseProtectionMiddleware } from "./abuse-guard";
|
|
13
|
-
import { createProcessConcurrencyMiddleware } from "./concurrency-guard";
|
|
14
13
|
import { createHeadroomGuard } from "./headroom-guard";
|
|
15
14
|
import {
|
|
16
15
|
createErrorNormalizationMiddleware,
|
|
@@ -27,7 +26,6 @@ import type { TrustedProxyBoundary } from "../../config/network";
|
|
|
27
26
|
import type { CartethyiaDatabase } from "../../persistence/db-handle";
|
|
28
27
|
import type { RedisClient } from "../../persistence/redis";
|
|
29
28
|
import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
|
|
30
|
-
import type { ModelStrikeService } from "../../security/model-abuse";
|
|
31
29
|
import type { RuntimeMemorySnapshot } from "../../observability/runtime-metrics";
|
|
32
30
|
import {
|
|
33
31
|
TelemetryPayloadCapture,
|
|
@@ -51,7 +49,6 @@ export interface TransportPipelineContext {
|
|
|
51
49
|
readonly requestDeadlineMs?: number;
|
|
52
50
|
readonly verifiedHttps?: boolean;
|
|
53
51
|
/** Graduated strikes for repeated invalid-model requests. */
|
|
54
|
-
readonly modelStrikes?: ModelStrikeService;
|
|
55
52
|
readonly shutdownCoordinator?: ShutdownDrainSource;
|
|
56
53
|
readonly telemetry?: TelemetryBatchBuffer;
|
|
57
54
|
/**
|
|
@@ -96,7 +93,6 @@ export interface TransportPipeline {
|
|
|
96
93
|
*/
|
|
97
94
|
export function createTransportPipeline(context: TransportPipelineContext): TransportPipeline {
|
|
98
95
|
const stages: Elysia[] = [
|
|
99
|
-
createProcessConcurrencyMiddleware(),
|
|
100
96
|
createHeadroomGuard({
|
|
101
97
|
store: context.stateStore,
|
|
102
98
|
...(context.memorySnapshot === undefined ? {} : { memorySnapshot: context.memorySnapshot }),
|
|
@@ -118,7 +114,6 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
|
|
|
118
114
|
createApiKeyAuthenticationMiddleware({
|
|
119
115
|
db: context.db,
|
|
120
116
|
stateStore: context.stateStore,
|
|
121
|
-
...(context.modelStrikes ? { modelStrikes: context.modelStrikes } : {}),
|
|
122
117
|
...(context.isInvokeDisabled
|
|
123
118
|
? { isInvokeDisabled: context.isInvokeDisabled }
|
|
124
119
|
: {}),
|
|
@@ -132,7 +127,6 @@ export function createTransportPipeline(context: TransportPipelineContext): Tran
|
|
|
132
127
|
createProxyRoutePreparationMiddleware({
|
|
133
128
|
stateStore: context.stateStore,
|
|
134
129
|
preparer: context.preparer,
|
|
135
|
-
...(context.modelStrikes ? { modelStrikes: context.modelStrikes } : {}),
|
|
136
130
|
}),
|
|
137
131
|
];
|
|
138
132
|
const mountRoot = (app: Elysia<any, any, any, any, any, any, any, any>): void => {
|
|
@@ -14,51 +14,9 @@ import { pushStructuredConsoleLog } from "../../observability/log-ring";
|
|
|
14
14
|
import { log } from "../../observability/logger";
|
|
15
15
|
import { isJsonProxyRoutePath, isProxyDispatchRoute } from "./body-policy";
|
|
16
16
|
import { isOpaqueBodyRoute } from "../contracts";
|
|
17
|
-
import {
|
|
18
|
-
modelAbuseBannedError,
|
|
19
|
-
modelWarningMessage,
|
|
20
|
-
type ModelAbuseOutcome,
|
|
21
|
-
type ModelStrikeService,
|
|
22
|
-
} from "../../security/model-abuse";
|
|
23
17
|
import { peerAddressUnavailableError } from "./guard-errors";
|
|
24
18
|
|
|
25
|
-
/**
|
|
26
|
-
* Whether a thrown error is a *model* rejection the strike layer counts: the
|
|
27
|
-
* key may not use the named model (`isModelAllowed` → 404), or the name
|
|
28
|
-
* resolves to nothing (`modelNotFoundError` → 404). Both surface as
|
|
29
|
-
* `model_not_found`; every other failure — an aborted request, a store outage,
|
|
30
|
-
* a capability mismatch — is not the caller probing for models and is not
|
|
31
|
-
* counted.
|
|
32
|
-
*/
|
|
33
|
-
function isModelRejection(error: unknown): boolean {
|
|
34
|
-
return error instanceof GatewayError && error.code === "model_not_found";
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Re-throws a model rejection carrying the escalating strike warning, so an
|
|
39
|
-
* honest client that mistyped sees "Warning 1 of 3" instead of the same generic
|
|
40
|
-
* 404 three times and then a ban. The code/status are unchanged — only the
|
|
41
|
-
* message gains the warning, and the count rides in `details` for the console.
|
|
42
|
-
*/
|
|
43
|
-
function withModelWarning(
|
|
44
|
-
error: unknown,
|
|
45
|
-
outcome: ModelAbuseOutcome,
|
|
46
|
-
limit: number,
|
|
47
|
-
opts?: { allowedModels?: readonly string[] | undefined },
|
|
48
|
-
): GatewayError {
|
|
49
|
-
if (!(error instanceof GatewayError)) return error as GatewayError;
|
|
50
|
-
const model = String(error.details.model ?? "");
|
|
51
|
-
const message = modelWarningMessage(model, outcome, limit);
|
|
52
|
-
const hint = opts?.allowedModels?.slice(0, 8);
|
|
53
|
-
return new GatewayError(error.code, error.status, message, {
|
|
54
|
-
...error.details,
|
|
55
|
-
strikes: outcome.strikes,
|
|
56
|
-
strike_limit: limit,
|
|
57
19
|
|
|
58
|
-
remaining_before_ban: Math.max(0, limit - outcome.strikes),
|
|
59
|
-
...(hint && hint.length > 0 ? { allowed_models_hint: hint } : {}),
|
|
60
|
-
}, error.origin);
|
|
61
|
-
}
|
|
62
20
|
|
|
63
21
|
interface RequestApp {
|
|
64
22
|
request(
|
|
@@ -71,7 +29,6 @@ export interface RequestContextDeps {
|
|
|
71
29
|
readonly clock?: () => number;
|
|
72
30
|
/** Explicit deadline override; defaults to `CARTETHYIA_UPSTREAM_TIMEOUT_MS` (120s). */
|
|
73
31
|
readonly requestDeadlineMs?: number;
|
|
74
|
-
readonly maxBodyBytes?: number;
|
|
75
32
|
}
|
|
76
33
|
|
|
77
34
|
/**
|
|
@@ -120,17 +77,25 @@ export function createClientIdentityMiddleware(deps: {
|
|
|
120
77
|
// pipeline position still guarantees this executes before IP-abuse checks.
|
|
121
78
|
return new Elysia()
|
|
122
79
|
.beforeHandle(({ request, server }) => {
|
|
123
|
-
const peer = deps.resolvePeerAddress
|
|
124
|
-
? deps.resolvePeerAddress(request)
|
|
125
|
-
: (server?.requestIP(request)?.address ?? null);
|
|
126
|
-
if (!peer)
|
|
127
|
-
throw peerAddressUnavailableError();
|
|
128
|
-
const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
|
|
129
80
|
const state = deps.stateStore.require(request);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
81
|
+
// The IP-abuse root hook already resolved the same identity from the
|
|
82
|
+
// same boundary (and only for /v1/*, exactly these routes); reuse it so
|
|
83
|
+
// a request pays one resolveClientIdentity instead of two. Fall back to
|
|
84
|
+
// resolving here when running in a reduced composition without the
|
|
85
|
+
// abuse guard.
|
|
86
|
+
if (!state.clientIdentity) {
|
|
87
|
+
const peer = deps.resolvePeerAddress
|
|
88
|
+
? deps.resolvePeerAddress(request)
|
|
89
|
+
: (server?.requestIP(request)?.address ?? null);
|
|
90
|
+
if (!peer)
|
|
91
|
+
throw peerAddressUnavailableError();
|
|
92
|
+
const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
|
|
93
|
+
state.clientIdentity = {
|
|
94
|
+
address,
|
|
95
|
+
source: address === peer ? "tcp-peer" : "trusted-forwarded-header",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const address = state.clientIdentity.address;
|
|
134
99
|
const path = state.ingressPath;
|
|
135
100
|
if (!isProxyDispatchRoute(path)) return;
|
|
136
101
|
// The ingress policy stage already decoded the body, so the client's
|
|
@@ -249,14 +214,6 @@ export function createCanonicalRequestMiddleware(deps: {
|
|
|
249
214
|
export function createProxyRoutePreparationMiddleware(deps: {
|
|
250
215
|
readonly stateStore: ProxyRequestStateStore;
|
|
251
216
|
readonly preparer: ProxyRequestPreparer;
|
|
252
|
-
/**
|
|
253
|
-
* Graduated strikes for repeated invalid-model requests. The preparation step
|
|
254
|
-
* is the single choke point where a request is rejected for naming a model the
|
|
255
|
-
* key may not use (`isModelAllowed`) or a model that resolves to nothing
|
|
256
|
-
* (`modelNotFoundError`), so it is where a strike is recorded and where a
|
|
257
|
-
* valid model clears one.
|
|
258
|
-
*/
|
|
259
|
-
readonly modelStrikes?: ModelStrikeService;
|
|
260
217
|
}): Elysia {
|
|
261
218
|
return new Elysia()
|
|
262
219
|
.beforeHandle(async ({ request }) => {
|
|
@@ -268,15 +225,8 @@ export function createProxyRoutePreparationMiddleware(deps: {
|
|
|
268
225
|
const state = deps.stateStore.require(request);
|
|
269
226
|
if (!state.authorization || !state.canonicalRequest)
|
|
270
227
|
throw new GatewayError("admission_unavailable", 503, "The request was not authenticated and parsed, so this route cannot be served. Resend the request.");
|
|
271
|
-
const strikes = deps.modelStrikes;
|
|
272
|
-
const ip = state.clientIdentity?.address;
|
|
273
228
|
// The key rides along so a strike is charged to the caller that made it.
|
|
274
229
|
// Without it, one recipient of a shared link could escalate a ban onto
|
|
275
|
-
// every other caller behind the same address.
|
|
276
|
-
const strikeIdentity =
|
|
277
|
-
strikes && ip !== undefined
|
|
278
|
-
? { strikes, ip, apiKeyId: state.authorization?.id ?? null }
|
|
279
|
-
: undefined;
|
|
280
230
|
try {
|
|
281
231
|
state.preparedRequest = await deps.preparer.prepare({
|
|
282
232
|
canonicalRequest: state.canonicalRequest,
|
|
@@ -288,31 +238,8 @@ export function createProxyRoutePreparationMiddleware(deps: {
|
|
|
288
238
|
: { clientUserAgent: state.clientUserAgent }),
|
|
289
239
|
});
|
|
290
240
|
} catch (error) {
|
|
291
|
-
// Only a *model* rejection is a strike: an invalid model the key may not
|
|
292
|
-
// use, or a name that resolves to nothing. A store outage, an aborted
|
|
293
|
-
// request, or any other failure is not the caller probing for models.
|
|
294
|
-
if (strikeIdentity && isModelRejection(error)) {
|
|
295
|
-
const outcome = await strikeIdentity.strikes
|
|
296
|
-
.noteInvalid({ ip: strikeIdentity.ip, apiKeyId: strikeIdentity.apiKeyId })
|
|
297
|
-
.catch(() => null);
|
|
298
|
-
if (outcome?.banned === true) throw modelAbuseBannedError();
|
|
299
|
-
if (outcome) {
|
|
300
|
-
const snap = state.authorization?.snapshot;
|
|
301
|
-
const raw = snap?.model_list;
|
|
302
|
-
const hint = Array.isArray(raw) ? (raw as readonly string[]) : raw instanceof Set ? [...raw] : undefined;
|
|
303
|
-
throw withModelWarning(error, outcome, strikeIdentity.strikes.limit, {
|
|
304
|
-
allowedModels: hint,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
241
|
throw error;
|
|
309
242
|
}
|
|
310
|
-
// A valid model does not reset the invalid-attempt window. Old attempts
|
|
311
|
-
// leave only when they age out of that window.
|
|
312
|
-
if (strikeIdentity)
|
|
313
|
-
void strikeIdentity.strikes
|
|
314
|
-
.noteValid({ ip: strikeIdentity.ip })
|
|
315
|
-
.catch(() => undefined);
|
|
316
243
|
})
|
|
317
244
|
.as("plugin");
|
|
318
245
|
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the planner warns (throttled here) when a request was dispatched degraded.
|
|
8
8
|
*/
|
|
9
9
|
import type { CanonicalRequest, ContentPart } from "../../protocol/canonical-model";
|
|
10
|
-
import { deriveRequiredCapabilities, type RequiredCapability } from "
|
|
10
|
+
import { deriveRequiredCapabilities, type RequiredCapability } from "../../protocol/translation/capabilities";
|
|
11
11
|
|
|
12
12
|
// Degradation is often sustained — a client that always asks for tools against
|
|
13
13
|
// a tools-less route degrades every single request. An unthrottled warn would
|
|
@@ -261,6 +261,25 @@ export function* degradedRequestVariants(
|
|
|
261
261
|
"audio",
|
|
262
262
|
] as RequiredCapability[];
|
|
263
263
|
const ordered = priority.filter((c) => required.includes(c));
|
|
264
|
+
// Pre-pass: drop exactly one capability from the pristine request. The
|
|
265
|
+
// cumulative chain below can only ADD losses, so when the plan's real blocker
|
|
266
|
+
// is a late capability in this order (an image on a text-only model), every
|
|
267
|
+
// surviving chain variant has already lost `tools` as collateral — a
|
|
268
|
+
// tool-capable model then served a transcript of `[tool:<name>]` placeholders
|
|
269
|
+
// with no tool catalog at all. Trying each single loss first finds the
|
|
270
|
+
// smallest degradation that satisfies routing: `degraded: [cap]` alone is
|
|
271
|
+
// always lexicographically smaller than the chain's `[...earlier, cap]`, so
|
|
272
|
+
// a single-loss win must be preferred, never the reverse.
|
|
273
|
+
for (const cap of ordered) {
|
|
274
|
+
const singular = degradeRequestForCapability(original, cap);
|
|
275
|
+
if (singular) {
|
|
276
|
+
yield {
|
|
277
|
+
request: singular,
|
|
278
|
+
degraded: [cap],
|
|
279
|
+
required: required.filter((c) => c !== cap),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
264
283
|
let current: CanonicalRequest = original;
|
|
265
284
|
const degraded: RequiredCapability[] = [];
|
|
266
285
|
for (const cap of ordered) {
|