cartethyia 2.5.4 → 2.5.7
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 +13 -4
- package/README.md +2 -2
- package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
- package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
- package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
- package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
- package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
- package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
- package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
- package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
- package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
- package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
- package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
- package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
- package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
- package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
- package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
- package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
- package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
- package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
- package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
- package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
- package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
- package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
- package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
- package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
- package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
- package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
- package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
- package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
- package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
- package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
- package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
- package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
- package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
- package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
- package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
- package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
- package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
- package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
- package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
- package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
- package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
- package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
- package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
- package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
- package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
- package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
- package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
- package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
- package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
- package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
- package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
- package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
- package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
- package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
- package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
- package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
- package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
- package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
- package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
- package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
- package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
- package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
- package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
- package/dist/dashboard/assets/tokens-COzario9.js +1 -0
- package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
- package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
- package/dist/dashboard/assets/useMutation-Dem0lXQj.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 -54
- package/migrations/README.md +52 -52
- package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
- package/package.json +95 -95
- package/scripts/cli/admin.ts +125 -0
- package/scripts/cli/{ui/background.ts → background.ts} +1 -1
- package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
- package/scripts/cli/doctor.ts +37 -0
- package/scripts/cli/{support/env.ts → env.ts} +99 -99
- package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
- package/scripts/cli/instance.ts +224 -0
- package/scripts/cli/main.ts +61 -18
- package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
- package/scripts/cli/port.ts +100 -0
- package/scripts/cli/{support/services.ts → services.ts} +88 -88
- package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
- package/scripts/cli/{command/start.ts → start.ts} +10 -5
- package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
- package/scripts/cli/tray/index.ts +62 -12
- package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
- package/scripts/cli/{command/update.ts → update.ts} +39 -39
- package/scripts/cli/version.ts +67 -0
- package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
- package/src/boot/app.ts +697 -693
- package/src/boot/dependencies.ts +647 -646
- package/src/boot/timeout.ts +2 -13
- package/src/config/cooldowns.ts +186 -138
- package/src/config/network.ts +8 -14
- package/src/config/providers.ts +7 -10
- package/src/config/runtime.ts +76 -76
- package/src/config/security.ts +15 -0
- package/src/config/spec.ts +20 -30
- package/src/config/telemetry.ts +0 -20
- package/src/config/timeouts.ts +0 -6
- package/src/console/admin/model-abuse/contracts.ts +28 -6
- package/src/console/admin/model-abuse/routes.ts +56 -3
- package/src/console/api-keys/contracts.ts +410 -414
- package/src/console/api-keys/routes.ts +881 -880
- package/src/console/auth/credentials.ts +3 -3
- package/src/console/auth/setup-service.ts +2 -2
- package/src/console/backup/export-endpoint.ts +6 -2
- package/src/console/backup/nine-router.ts +2 -5
- package/src/console/backup/service.ts +4 -0
- package/src/console/backup/store.ts +192 -29
- package/src/console/backup/validate.ts +3 -45
- package/src/console/cli-tools/fs-ops.ts +487 -487
- package/src/console/console-router-guards.ts +1 -1
- package/src/console/data-management/contracts.ts +13 -6
- package/src/console/data-management/operations.ts +23 -18
- package/src/console/data-management/routes.ts +0 -1
- package/src/console/domain-registration.ts +10 -5
- package/src/console/observability/live.ts +22 -0
- package/src/console/observability/store.ts +39 -16
- package/src/console/observability/usage-dimensions.ts +4 -4
- package/src/console/providers/catalog/catalog-projections.ts +6 -7
- package/src/console/providers/catalog/model-operations.ts +5 -2
- package/src/console/providers/catalog/provider-operations.ts +4 -4
- package/src/console/providers/catalog/public-model-store.ts +8 -11
- package/src/console/providers/catalog/store.ts +1553 -1553
- package/src/console/providers/oauth/routes.ts +8 -12
- package/src/console/quota/account-quota-tenant.ts +6 -6
- package/src/console/quota/quota-view.ts +1 -1
- package/src/console/remote-support/contracts.ts +61 -0
- package/src/console/remote-support/routes.ts +65 -0
- package/src/console/remote-support/service.ts +207 -0
- package/src/console/response-compression.ts +0 -3
- package/src/console/routing/pools/probe-result.ts +3 -2
- package/src/console/routing/pools/probe-verdict.ts +132 -132
- package/src/console/routing/pools/store.ts +8 -8
- package/src/console/settings/contracts.ts +58 -2
- package/src/console/settings/store.ts +30 -3
- package/src/console/shared/cursor.ts +8 -8
- package/src/console/shared/errors.ts +1 -3
- package/src/network/http2-bidi.ts +2 -4
- package/src/network/pool/agent.ts +478 -478
- package/src/network/pool/selector.ts +5 -4
- package/src/network/retry.ts +1 -2
- package/src/network/ssrf.ts +5 -4
- package/src/observability/event-compaction.ts +80 -78
- package/src/observability/event-day-index.ts +13 -15
- package/src/observability/event-log.ts +4 -4
- package/src/observability/logger.ts +6 -6
- package/src/observability/payload-capture.ts +151 -33
- package/src/observability/payload-store.ts +397 -440
- package/src/observability/redaction.ts +5 -6
- package/src/observability/request-notify.ts +43 -0
- package/src/observability/telemetry-status.ts +8 -9
- package/src/observability/token-speed.ts +5 -4
- package/src/persistence/db-mode.ts +7 -7
- package/src/persistence/readiness.ts +1 -1
- package/src/persistence/schema.ts +1406 -1402
- package/src/persistence/shared-cache.ts +1 -2
- package/src/persistence/stores/api-key-store.ts +18 -6
- package/src/persistence/stores/share-store.ts +4 -3
- package/src/persistence/stores/telemetry-store.ts +236 -237
- package/src/protocol/canonical-model.ts +11 -3
- package/src/protocol/codecs.ts +138 -4
- package/src/protocol/gateway-error.ts +6 -4
- package/src/protocol/request/chat.ts +21 -1
- package/src/protocol/request/codex.ts +10 -1
- package/src/protocol/request/messages.ts +4 -5
- package/src/protocol/request/responses.ts +9 -1
- package/src/protocol/response/chat.ts +34 -4
- package/src/protocol/response/codex.ts +7 -4
- package/src/protocol/response/dsml.ts +140 -28
- package/src/protocol/response/responses.ts +11 -5
- package/src/protocol/stream-error-frames.ts +4 -5
- package/src/providers/authentication/jwt-validator.ts +9 -10
- package/src/providers/authentication/oauth-refresh-service.ts +9 -11
- package/src/providers/configured-openai-providers.ts +134 -136
- package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
- package/src/providers/discovery/catalog/catalog-format.ts +3 -6
- package/src/providers/discovery/models-dev-catalog.ts +354 -354
- package/src/providers/discovery/probe-phases.ts +4 -4
- package/src/providers/discovery/probe-wire.ts +6 -13
- package/src/providers/discovery/probing-service.ts +1117 -1045
- package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
- package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
- package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
- package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
- package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
- package/src/providers/integrations/buddy/buddy-media.ts +2 -2
- package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
- package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
- package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
- package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
- package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
- package/src/providers/integrations/claude/claude.ts +608 -608
- package/src/providers/integrations/cline/cline-quota.ts +132 -132
- package/src/providers/integrations/cline/cline.ts +537 -539
- package/src/providers/integrations/codex/codex-oauth.ts +1 -1
- package/src/providers/integrations/commandcode/commandcode.ts +674 -675
- package/src/providers/integrations/commandcode/discovery.ts +117 -116
- package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
- package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
- package/src/providers/integrations/cursor/cursor.ts +11 -163
- package/src/providers/integrations/factory/factory-models.ts +3 -5
- package/src/providers/integrations/grok/grok-install-id.ts +2 -3
- package/src/providers/integrations/grok/grok.ts +6 -7
- package/src/providers/integrations/hermes.ts +14 -18
- package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
- package/src/providers/integrations/kiro/kiro.ts +1 -1
- package/src/providers/integrations/mistral.ts +6 -6
- package/src/providers/integrations/opencode/fingerprint.ts +3 -3
- package/src/providers/integrations/opencode/provider.ts +8 -8
- package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
- package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
- package/src/providers/integrations/qoder/qoder.ts +884 -865
- package/src/providers/integrations/shared/connect.ts +6 -6
- package/src/providers/integrations/trae/trae-catalog.ts +1 -2
- package/src/providers/integrations/trae/trae.ts +2 -2
- package/src/providers/integrations/zcode/oauth.ts +6 -1
- package/src/providers/operations/account-health-service.ts +1528 -1523
- package/src/providers/operations/account-reset-service.ts +1 -1
- package/src/providers/operations/client-versions.ts +6 -6
- package/src/providers/operations/custom-cli-headers.ts +3 -4
- package/src/providers/operations/provider-catalog-seeder.ts +95 -95
- package/src/providers/operations/provider-catalog-service.ts +3 -4
- package/src/providers/provider-metadata.ts +4 -8
- package/src/providers/provider-registry.ts +6 -7
- package/src/providers/provider-usage.ts +1 -1
- package/src/security/abuse.ts +15 -10
- package/src/security/access-control.ts +144 -146
- package/src/security/admission/contracts.ts +11 -0
- package/src/security/admission/in-memory-store.ts +4 -3
- package/src/security/admission/lease-sweep.ts +6 -6
- package/src/security/admission/reasons.ts +5 -1
- package/src/security/admission/redis-store.ts +211 -210
- package/src/security/admission/service.ts +4 -0
- package/src/security/api-key-auth.ts +504 -504
- package/src/security/ip-boundary.ts +155 -15
- package/src/security/model-abuse.ts +366 -94
- package/src/security/outbound-headers.ts +3 -4
- package/src/shared/sleep.ts +4 -0
- package/src/transport/dispatch/attempt-finalize.ts +47 -5
- package/src/transport/dispatch/attempt-loop.ts +531 -530
- package/src/transport/dispatch/stream-bridge.ts +1 -3
- package/src/transport/dispatch/streaming/finalize.ts +1 -1
- package/src/transport/dispatch/streaming/stream.ts +5 -6
- package/src/transport/failure-policy.ts +162 -8
- package/src/transport/guards/auth-guard.ts +7 -4
- package/src/transport/guards/concurrency-guard.ts +0 -5
- package/src/transport/guards/error-lifecycle.ts +13 -6
- package/src/transport/guards/pipeline.ts +15 -3
- package/src/transport/guards/request-context.ts +8 -2
- package/src/transport/request/inflight.ts +5 -6
- package/src/transport/request/preparer.ts +2 -2
- package/src/transport/request/state.ts +12 -8
- package/src/transport/routing/admission.ts +4 -0
- package/src/transport/routing/route-catalog.ts +8 -1
- package/src/transport/routing/route-model.ts +46 -14
- package/src/transport/routing/router.ts +21 -14
- package/src/transport/surface/completion.ts +2 -3
- package/src/transport/surface/dialects.ts +5 -6
- package/src/transport/surface/responses/encode.ts +38 -0
- package/src/transport/surface/responses/parse.ts +4 -0
- package/src/transport/translation/capabilities.ts +2 -3
- package/src/transport/translation/quirks.ts +3 -3
- package/src/workers/buddy-growth-tasks.ts +5 -10
- package/src/workers/daily-checkin.ts +17 -23
- package/src/workers/quota-refresh-sweep.ts +26 -26
- package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
- package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
- package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
- package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
- package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
- package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
- package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
- package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
- package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
- package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
- package/dist/dashboard/assets/app-COlK95oi.css +0 -2
- package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
- package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
- package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
- package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
- package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
- package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
- package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
- package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
- package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
- package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
- package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
- package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
- package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
- package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
- package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
- package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
- package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
- package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
- package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
- package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
- package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
- package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
- package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
- package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
- package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
- package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
- package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
- package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
- package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
- package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
- package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
- package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
- package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
- package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
- package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
- package/scripts/cli/command/admin.ts +0 -65
- package/scripts/cli/command/doctor.ts +0 -257
- package/scripts/cli/gateway/drain-token.ts +0 -78
- package/scripts/cli/gateway/instance-lock.ts +0 -105
- package/scripts/cli/gateway/stop.ts +0 -51
- package/scripts/cli/support/bun.ts +0 -27
- package/scripts/cli/support/version.ts +0 -37
- /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
- /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
- /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
package/src/boot/app.ts
CHANGED
|
@@ -1,693 +1,697 @@
|
|
|
1
|
-
import { Elysia } from "elysia";
|
|
2
|
-
import { resolveDashboardDist, resolveElysiaPrecompile } from "../config/runtime";
|
|
3
|
-
import { createConsoleRouter, type ConsoleApiCompositionDeps } from "../console/console-router";
|
|
4
|
-
import type { ScheduledTaskRegistry } from "../workers/scheduler";
|
|
5
|
-
import { createStaticHandler } from "../console/dashboard-assets";
|
|
6
|
-
import { createShareRouter } from "../console/share/share-router";
|
|
7
|
-
import { createShareStatsPort } from "../console/share/share-stats";
|
|
8
|
-
import { DrizzleShareLinkStore } from "../persistence/stores/share-store";
|
|
9
|
-
import { chatAdapter } from "../transport/surface/chat/adapter";
|
|
10
|
-
import { responsesAdapter } from "../transport/surface/responses/adapter";
|
|
11
|
-
import { messagesAdapter } from "../transport/surface/messages/adapter";
|
|
12
|
-
import { completionAdapter } from "../transport/surface/completion";
|
|
13
|
-
import { SurfaceAdapterRegistry } from "../transport/surface/adapters";
|
|
14
|
-
import { GatewayError, publicGatewayErrorBody } from "../protocol/gateway-error";
|
|
15
|
-
import { shutdownError, shutdownNotice } from "../transport/shutdown-notice";
|
|
16
|
-
import type { CanonicalAdapter } from "../transport/contracts";
|
|
17
|
-
import type { ApiKeyAuthorizationSnapshot } from "../security/api-key-auth";
|
|
18
|
-
import { modelsListApiKeyError } from "../security/api-key-auth";
|
|
19
|
-
import { getDbHandle } from "../persistence/boot";
|
|
20
|
-
import { isPgHandle } from "../persistence/db-pg";
|
|
21
|
-
import { resolveRedisClient } from "../persistence/redis";
|
|
22
|
-
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
23
|
-
import type { ProviderAdapter } from "../providers/provider-contracts";
|
|
24
|
-
import type { OAuthTokenRefresher, OAuthRefreshService } from "../providers/authentication/oauth-refresh-service";
|
|
25
|
-
import { withTimeout } from "./timeout";
|
|
26
|
-
import type { ByokUpstreamHost } from "../providers/operations/provider-catalog-service";
|
|
27
|
-
import type { NetworkPoolSelector } from "../network/pool/selector";
|
|
28
|
-
import type { ProxyRequestPreparer } from "../transport/request/preparer";
|
|
29
|
-
import { ProxyRequestStateStore } from "../transport/request/state";
|
|
30
|
-
import { fastPathname } from "../transport/pathname";
|
|
31
|
-
import {
|
|
32
|
-
handleProviderProxyRequest,
|
|
33
|
-
type ProviderProxyHandlerDeps,
|
|
34
|
-
} from "../transport/dispatch/proxy-request";
|
|
35
|
-
import { createResponsesCompactHandler } from "../transport/dispatch/responses-compact";
|
|
36
|
-
import { createSystemoneHandler } from "../transport/dispatch/systemone";
|
|
37
|
-
import { createWebsearchHandler } from "../transport/dispatch/websearch";
|
|
38
|
-
import {
|
|
39
|
-
createAudioSpeechHandler,
|
|
40
|
-
createAudioTranscriptionHandler,
|
|
41
|
-
createEmbeddingHandler,
|
|
42
|
-
createImageGenerationHandler,
|
|
43
|
-
createVideoGenerationHandler,
|
|
44
|
-
} from "../transport/dispatch/generation";
|
|
45
|
-
import { createTokenCountHandler } from "../transport/dispatch/token-count";
|
|
46
|
-
|
|
47
|
-
import { createTransportPipeline } from "../transport/guards/pipeline";
|
|
48
|
-
import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
|
|
49
|
-
import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
|
|
50
|
-
import { resetConsoleAuthState } from "../console/auth/account-reset-store";
|
|
51
|
-
import { createBackupExportHandler } from "../console/backup/export-endpoint";
|
|
52
|
-
import { tenants } from "../persistence/schema";
|
|
53
|
-
import { PublicModelCatalogStore, type AllowedModelEntry } from "../console/providers/catalog/public-model-store";
|
|
54
|
-
import type { IpAbuseProtectionService } from "../security/abuse";
|
|
55
|
-
import type { ReadinessCheckResult } from "../persistence/readiness";
|
|
56
|
-
import type { RouteSnapshotService } from "../transport/routing/route-model";
|
|
57
|
-
import type { TrustedProxyBoundary } from "../config/network";
|
|
58
|
-
import { resolveClientIdentity } from "../security/ip-boundary";
|
|
59
|
-
import type { ValidatedNetworkBindingFactory } from "../network/pool/resolver";
|
|
60
|
-
import { metrics } from "../observability/metrics";
|
|
61
|
-
import type { RuntimeMetricsSampler } from "../observability/runtime-metrics";
|
|
62
|
-
import { API_CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS } from "../security/outbound-headers";
|
|
63
|
-
import type { ModelStrikeService } from "../security/model-abuse";
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
|
|
67
|
-
import type { ShutdownCoordinator } from "./lifecycle";
|
|
68
|
-
import type { ShutdownDrainSource } from "../transport/contracts";
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Route-only shell dependencies: the dashboard, `/health`, and `/metrics`
|
|
72
|
-
* without the transport pipeline. Used when no database is available (AOT
|
|
73
|
-
* manifest capture during `bun run build:aot`) and by tests that exercise
|
|
74
|
-
* routing and static serving in isolation.
|
|
75
|
-
*
|
|
76
|
-
* Only the knobs route-only mode actually reads appear here. Transport and
|
|
77
|
-
* console options (`db`, `resolvePeerAddress`, `trustedProxyBoundary`,
|
|
78
|
-
* `maxBodyBytes`, `requestDeadlineMs`, `verifiedHttps`) are deliberately
|
|
79
|
-
* absent: they are read inside the `mode === "production"` branches, where the
|
|
80
|
-
* discriminant narrows `deps` to `ProductionAppDeps`. Listing them here would
|
|
81
|
-
* let a caller pass a value the shell silently ignores.
|
|
82
|
-
*/
|
|
83
|
-
|
|
84
|
-
readonly mode: "route-only";
|
|
85
|
-
readonly dashboardDist?: string;
|
|
86
|
-
readonly checkDb?: () => Promise<void>;
|
|
87
|
-
readonly checkRedis?: () => Promise<void>;
|
|
88
|
-
readonly readiness?: () => Promise<ReadinessCheckResult>;
|
|
89
|
-
/**
|
|
90
|
-
* Drain state the readiness route reads. The shell has no coordinator, so it
|
|
91
|
-
* takes the narrow read surface (`ShutdownDrainSource`) rather than a
|
|
92
|
-
* hand-maintained copy of the whole `ShutdownCoordinator`.
|
|
93
|
-
*/
|
|
94
|
-
readonly shutdownCoordinator?: ShutdownDrainSource;
|
|
95
|
-
/**
|
|
96
|
-
* Memory sampler when the caller owns one. The shell never constructs it, so
|
|
97
|
-
* it stays optional; production always supplies it and binds its in-flight
|
|
98
|
-
* source to the request store below.
|
|
99
|
-
*/
|
|
100
|
-
readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Full production dependencies. Every transport, pool, snapshot, telemetry,
|
|
105
|
-
* and console field is required — the composition root in
|
|
106
|
-
* `runtime/dependencies.ts` either supplies it or the app does not boot, so
|
|
107
|
-
* there is no runtime shape check to fall through.
|
|
108
|
-
*/
|
|
109
|
-
export interface ProductionAppDeps {
|
|
110
|
-
readonly mode: "production";
|
|
111
|
-
readonly db: CartethyiaDatabase;
|
|
112
|
-
readonly proxyPreparer: ProxyRequestPreparer;
|
|
113
|
-
readonly resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
|
|
114
|
-
readonly providerAdapters?: ReadonlyMap<string, ProviderAdapter>;
|
|
115
|
-
/** Live lookup of a provider's SSRF-validated upstream host. */
|
|
116
|
-
readonly byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
|
|
117
|
-
readonly networkBindingFactory: ValidatedNetworkBindingFactory;
|
|
118
|
-
readonly ipAbuseProtection: IpAbuseProtectionService;
|
|
119
|
-
readonly trustedProxyBoundary: TrustedProxyBoundary;
|
|
120
|
-
readonly poolSelector: NetworkPoolSelector;
|
|
121
|
-
readonly snapshotService: RouteSnapshotService;
|
|
122
|
-
readonly readiness: () => Promise<ReadinessCheckResult>;
|
|
123
|
-
readonly telemetryBuffer: TelemetryBatchBuffer;
|
|
124
|
-
readonly resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
|
|
125
|
-
readonly oauthRefreshService: OAuthRefreshService;
|
|
126
|
-
/** Graduated strikes for repeated invalid-model requests. */
|
|
127
|
-
readonly modelStrikes?: ModelStrikeService;
|
|
128
|
-
/**
|
|
129
|
-
* The console control plane. Optional so reduced compositions (route-only
|
|
130
|
-
* shell, console stubs) stay valid; production always mounts it. Without
|
|
131
|
-
* REDIS_URL the console runs on the in-memory backend like the data plane
|
|
132
|
-
* (OAuth-flow state and the quota cache fall back to process memory;
|
|
133
|
-
* sessions live in `console_sessions` in Postgres and CSRF is stateless).
|
|
134
|
-
*/
|
|
135
|
-
readonly consoleApi?: ConsoleApiCompositionDeps;
|
|
136
|
-
/**
|
|
137
|
-
* The shared maintenance scheduler. The in-flight backstop sweep registers
|
|
138
|
-
* here rather than owning a private timer, so every periodic task in the
|
|
139
|
-
* process is visible in one place. Absent in reduced compositions, where the
|
|
140
|
-
* backstop is not mounted.
|
|
141
|
-
*/
|
|
142
|
-
readonly scheduledTasks?: ScheduledTaskRegistry;
|
|
143
|
-
/** Memory sampler; bound to the request store created below. */
|
|
144
|
-
readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
|
|
145
|
-
/** The real coordinator: production wires its abort hook and tracks requests. */
|
|
146
|
-
readonly shutdownCoordinator: ShutdownCoordinator;
|
|
147
|
-
readonly dashboardDist?: string;
|
|
148
|
-
readonly resolvePeerAddress?: (request: Request) => string | null;
|
|
149
|
-
readonly maxBodyBytes?: number;
|
|
150
|
-
readonly requestDeadlineMs?: number;
|
|
151
|
-
readonly verifiedHttps?: boolean;
|
|
152
|
-
/**
|
|
153
|
-
* Secret for the operator drain endpoint. When set (with `triggerDrain`),
|
|
154
|
-
* `POST /admin/drain` from loopback with a matching `x-drain-token` drains
|
|
155
|
-
* gracefully — the signal-free stop Windows needs.
|
|
156
|
-
*/
|
|
157
|
-
readonly drainToken?: string;
|
|
158
|
-
readonly triggerDrain?: () => void;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Either app mode. The discriminant decides which builder path runs. */
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
function checkDatabaseConnection(): Promise<void> {
|
|
165
|
-
return getDbHandle()
|
|
166
|
-
.query("SELECT 1")
|
|
167
|
-
.then(() => undefined);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function checkRedisConnection(): Promise<void> {
|
|
171
|
-
// Memory backend is healthy by construction — there is no connection to
|
|
172
|
-
// probe — so readiness only pings a real client.
|
|
173
|
-
const redis = resolveRedisClient();
|
|
174
|
-
if (!redis) return Promise.resolve();
|
|
175
|
-
return redis.ping().then(() => undefined);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
function runDependencyReadinessCheck(check: () => Promise<void>, label: string): Promise<void> {
|
|
179
|
-
return withTimeout(Promise.resolve().then(check), 2000, `${label} timeout after 2000ms`);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* Builds the public HTTP composition root with route ordering guarantee.
|
|
184
|
-
* The one builder behind both modes: `deps.mode` selects which planes mount,
|
|
185
|
-
* and each mode's dependency interface admits no partial set.
|
|
186
|
-
*/
|
|
187
|
-
export function createGatewayApp(deps: GatewayAppDeps) {
|
|
188
|
-
const dbCheck =
|
|
189
|
-
deps.mode === "route-only"
|
|
190
|
-
? (deps.checkDb ?? checkDatabaseConnection)
|
|
191
|
-
: checkDatabaseConnection;
|
|
192
|
-
const redisCheck =
|
|
193
|
-
deps.mode === "route-only"
|
|
194
|
-
? (deps.checkRedis ?? checkRedisConnection)
|
|
195
|
-
: checkRedisConnection;
|
|
196
|
-
const registry = new SurfaceAdapterRegistry([
|
|
197
|
-
chatAdapter,
|
|
198
|
-
responsesAdapter,
|
|
199
|
-
messagesAdapter,
|
|
200
|
-
completionAdapter,
|
|
201
|
-
]);
|
|
202
|
-
const dashboardDist = deps.dashboardDist ?? resolveDashboardDist();
|
|
203
|
-
const staticHandler = createStaticHandler({ buildDir: dashboardDist });
|
|
204
|
-
const peerAddresses = new WeakMap<Request, string>();
|
|
205
|
-
const serveDashboard = async ({ request }: { request: Request }): Promise<Response> => {
|
|
206
|
-
const pathname = fastPathname(request.url);
|
|
207
|
-
// The client's `accept-encoding` is what decides whether a precompressed
|
|
208
|
-
// `.gz` sibling can be served instead of the plain asset.
|
|
209
|
-
const result = await staticHandler(pathname, request.headers.get("accept-encoding"));
|
|
210
|
-
return new Response(result.body ? (result.body as unknown as BodyInit) : null, {
|
|
211
|
-
status: result.status,
|
|
212
|
-
headers: result.headers,
|
|
213
|
-
});
|
|
214
|
-
};
|
|
215
|
-
/**
|
|
216
|
-
* Root catch-all. Non-document namespaces (`/v1`, `/health`, `/metrics`) keep
|
|
217
|
-
* their JSON 404 so an unknown API path can never be answered with the SPA
|
|
218
|
-
* document; everything else resolves through the static handler.
|
|
219
|
-
*/
|
|
220
|
-
const API_NAMESPACES = ["/v1", "/health", "/metrics"] as const;
|
|
221
|
-
const serveRootFallback = async ({ request }: { request: Request }): Promise<Response> => {
|
|
222
|
-
const pathname = fastPathname(request.url);
|
|
223
|
-
const reserved = API_NAMESPACES.some(
|
|
224
|
-
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
|
225
|
-
);
|
|
226
|
-
if (reserved) {
|
|
227
|
-
const notFound = new GatewayError("not_found", 404, "No endpoint exists at this path. Check the route against the API reference.");
|
|
228
|
-
return new Response(JSON.stringify(publicGatewayErrorBody(notFound)), {
|
|
229
|
-
status: notFound.status,
|
|
230
|
-
headers: {
|
|
231
|
-
"content-type": "application/json; charset=utf-8",
|
|
232
|
-
"cache-control": "no-store",
|
|
233
|
-
"content-security-policy": API_CONTENT_SECURITY_POLICY,
|
|
234
|
-
"x-frame-options": X_FRAME_OPTIONS,
|
|
235
|
-
"x-content-type-options": "nosniff",
|
|
236
|
-
},
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
return serveDashboard({ request });
|
|
240
|
-
};
|
|
241
|
-
|
|
242
|
-
// Build the Elysia app with strict route ordering.
|
|
243
|
-
const requestStateStore = new ProxyRequestStateStore(
|
|
244
|
-
deps.mode === "production" ? deps.shutdownCoordinator : undefined,
|
|
245
|
-
);
|
|
246
|
-
deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
|
|
247
|
-
// Shutdown ordering: abort in-flight proxy controllers first so the
|
|
248
|
-
// bounded drain observes cancellation and finalizers run before
|
|
249
|
-
// telemetry flush and pool close. The reason is threaded through so the
|
|
250
|
-
// streaming path emits a drain-aware terminal frame (`restart_for_update`
|
|
251
|
-
// vs `shutting_down`) instead of a bare abort that reads as a client drop.
|
|
252
|
-
// Only production has a coordinator to wire; the shell never drains.
|
|
253
|
-
if (deps.mode === "production") {
|
|
254
|
-
const coordinator = deps.shutdownCoordinator;
|
|
255
|
-
coordinator.setAbortInflight(() =>
|
|
256
|
-
requestStateStore.abortAll(shutdownError(coordinator.shutdownReason())),
|
|
257
|
-
);
|
|
258
|
-
}
|
|
259
|
-
// Backstop: the gauge must settle even if an abort path never fires (a
|
|
260
|
-
// half-closed socket that neither pulls, cancels, nor aborts). This sweep
|
|
261
|
-
// force-releases any flight past its deadline plus a grace window, so a
|
|
262
|
-
// missed release cannot leave a permanently wrong number. The grace is a
|
|
263
|
-
// safety margin over the request deadline, not a deployment knob.
|
|
264
|
-
const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
|
|
265
|
-
const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
|
|
266
|
-
const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
|
|
267
|
-
if (deps.mode === "production") deps.scheduledTasks?.register({
|
|
268
|
-
name: "inflight-backstop",
|
|
269
|
-
intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
|
|
270
|
-
run: () => {
|
|
271
|
-
requestStateStore.sweepOverdueInFlight(
|
|
272
|
-
Date.now(),
|
|
273
|
-
INFLIGHT_BACKSTOP_GRACE_MS,
|
|
274
|
-
INFLIGHT_BACKSTOP_HARD_GRACE_MS,
|
|
275
|
-
);
|
|
276
|
-
},
|
|
277
|
-
});
|
|
278
|
-
const app = new Elysia({ precompile: resolveElysiaPrecompile() })
|
|
279
|
-
.beforeHandle(
|
|
280
|
-
({
|
|
281
|
-
request,
|
|
282
|
-
server,
|
|
283
|
-
}: {
|
|
284
|
-
request: Request;
|
|
285
|
-
server?: { requestIP(request: Request): { address: string } | null } | null;
|
|
286
|
-
}) => {
|
|
287
|
-
const address = server?.requestIP(request)?.address;
|
|
288
|
-
if (address) peerAddresses.set(request, address);
|
|
289
|
-
},
|
|
290
|
-
)
|
|
291
|
-
.get("/health", () => ({ status: "ok" as const }))
|
|
292
|
-
.get("/health/ready", async () => {
|
|
293
|
-
// A draining process reports not-ready so the orchestrator stops
|
|
294
|
-
// sending it traffic and the replacement takes over without the old
|
|
295
|
-
// one crashing first: SIGTERM → draining → 503 here → Docker routes
|
|
296
|
-
// to the new container → old one finishes in flight and exits 0.
|
|
297
|
-
// The reason distinguishes an ordinary stop from an in-place update,
|
|
298
|
-
// whose replacement is seconds away, so a probe consumer can back off
|
|
299
|
-
// briefly instead of treating the drain as a permanent outage.
|
|
300
|
-
if (deps.shutdownCoordinator?.isDraining()) {
|
|
301
|
-
const notice = shutdownNotice(deps.shutdownCoordinator.shutdownReason?.());
|
|
302
|
-
return new Response(JSON.stringify({ status: "not_ready" as const, reason: notice.code }), {
|
|
303
|
-
status: 503,
|
|
304
|
-
headers: { "content-type": "application/json" },
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
|
-
const readiness = deps.readiness ? await deps.readiness() : undefined;
|
|
308
|
-
const results = readiness
|
|
309
|
-
? [
|
|
310
|
-
{ status: readiness.db === "connected" ? "fulfilled" : "rejected" },
|
|
311
|
-
{ status: readiness.redis !== "disconnected" ? "fulfilled" : "rejected" },
|
|
312
|
-
]
|
|
313
|
-
: await Promise.allSettled([
|
|
314
|
-
runDependencyReadinessCheck(dbCheck, "db"),
|
|
315
|
-
runDependencyReadinessCheck(redisCheck, "redis"),
|
|
316
|
-
]);
|
|
317
|
-
const dbOk = results[0]?.status === "fulfilled";
|
|
318
|
-
const redisOk = results[1]?.status === "fulfilled";
|
|
319
|
-
const migrationsOk = readiness ? readiness.migrations === "applied" : true;
|
|
320
|
-
|
|
321
|
-
if (dbOk && redisOk && migrationsOk) {
|
|
322
|
-
return {
|
|
323
|
-
status: "ready" as const,
|
|
324
|
-
db: "connected" as const,
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
return new Response(
|
|
329
|
-
JSON.stringify({
|
|
330
|
-
status: "not_ready" as const,
|
|
331
|
-
db: dbOk ? ("connected" as const) : ("disconnected" as const),
|
|
332
|
-
redis: redisOk ? ("connected" as const) : ("disconnected" as const),
|
|
333
|
-
...(readiness ? { migrations: readiness.migrations } : {}),
|
|
334
|
-
reason: !migrationsOk
|
|
335
|
-
? "database migrations pending"
|
|
336
|
-
: "dependency not ready",
|
|
337
|
-
}),
|
|
338
|
-
{ status: 503, headers: { "content-type": "application/json" } },
|
|
339
|
-
);
|
|
340
|
-
})
|
|
341
|
-
.get("/metrics", () => {
|
|
342
|
-
const handle = getDbHandle();
|
|
343
|
-
// Pool gauges describe the external server's connection budget; the
|
|
344
|
-
// embedded backend has no pool to report.
|
|
345
|
-
if (isPgHandle(handle)) {
|
|
346
|
-
metrics.cartethyia_pg_pool_total.set(handle.pool.totalCount);
|
|
347
|
-
metrics.cartethyia_pg_pool_idle.set(handle.pool.idleCount);
|
|
348
|
-
metrics.cartethyia_pg_pool_waiting.set(handle.pool.waitingCount);
|
|
349
|
-
}
|
|
350
|
-
// No client means the memory backend, which is always up; a real client
|
|
351
|
-
// reports its own readiness so the scrape never throws.
|
|
352
|
-
const redisClient = resolveRedisClient();
|
|
353
|
-
metrics.cartethyia_redis_up.set(redisClient === undefined || redisClient.status === "ready" ? 1 : 0);
|
|
354
|
-
return new Response(metrics.render(), {
|
|
355
|
-
headers: { "content-type": "text/plain; version=0.0.4" },
|
|
356
|
-
});
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
if (deps.mode === "production") {
|
|
360
|
-
const transportPipeline = createTransportPipeline({
|
|
361
|
-
db: deps.db,
|
|
362
|
-
stateStore: requestStateStore,
|
|
363
|
-
surfaceRegistry: registry,
|
|
364
|
-
adapters: new Map<string, CanonicalAdapter>([
|
|
365
|
-
["chat", chatAdapter],
|
|
366
|
-
["responses", responsesAdapter],
|
|
367
|
-
["messages", messagesAdapter],
|
|
368
|
-
["completion", completionAdapter],
|
|
369
|
-
]),
|
|
370
|
-
preparer: deps.proxyPreparer,
|
|
371
|
-
readiness: deps.readiness,
|
|
372
|
-
trustedProxyBoundary: deps.trustedProxyBoundary,
|
|
373
|
-
...(deps.resolvePeerAddress ? { resolvePeerAddress: deps.resolvePeerAddress } : {}),
|
|
374
|
-
ipAbuseProtection: deps.ipAbuseProtection,
|
|
375
|
-
...(deps.maxBodyBytes === undefined ? {} : { maxBodyBytes: deps.maxBodyBytes }),
|
|
376
|
-
...(deps.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: deps.requestDeadlineMs }),
|
|
377
|
-
...(deps.verifiedHttps ? { verifiedHttps: true } : {}),
|
|
378
|
-
shutdownCoordinator: deps.shutdownCoordinator,
|
|
379
|
-
telemetry: deps.telemetryBuffer,
|
|
380
|
-
...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
|
|
381
|
-
...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
|
|
382
|
-
});
|
|
383
|
-
transportPipeline.mountRoot(app);
|
|
384
|
-
const proxyDeps: ProviderProxyHandlerDeps = {
|
|
385
|
-
db: deps.db,
|
|
386
|
-
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
387
|
-
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
388
|
-
stateStore: requestStateStore,
|
|
389
|
-
proxyPreparer: deps.proxyPreparer,
|
|
390
|
-
snapshotService: deps.snapshotService,
|
|
391
|
-
networkBindingFactory: deps.networkBindingFactory,
|
|
392
|
-
byokUpstreamHosts: deps.byokUpstreamHosts,
|
|
393
|
-
poolSelector: deps.poolSelector,
|
|
394
|
-
telemetryBuffer: deps.telemetryBuffer,
|
|
395
|
-
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
396
|
-
oauthRefreshService: deps.oauthRefreshService,
|
|
397
|
-
};
|
|
398
|
-
const proxyHandler = ({ request }: { request: Request }) =>
|
|
399
|
-
handleProviderProxyRequest(request, proxyDeps);
|
|
400
|
-
|
|
401
|
-
const publicModelStore = new PublicModelCatalogStore(deps.db);
|
|
402
|
-
const queryAllowedModels = (auth: {
|
|
403
|
-
snapshot: ApiKeyAuthorizationSnapshot;
|
|
404
|
-
tenantId: string | null;
|
|
405
|
-
modelPrefix?: string;
|
|
406
|
-
}): Promise<AllowedModelEntry[]> =>
|
|
407
|
-
publicModelStore.listPublicModels(auth.tenantId, auth.snapshot, auth.modelPrefix);
|
|
408
|
-
|
|
409
|
-
const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
|
|
410
|
-
const state = requestStateStore.require(request);
|
|
411
|
-
const auth = state.authorization;
|
|
412
|
-
if (!auth) throw modelsListApiKeyError();
|
|
413
|
-
const data = await queryAllowedModels(auth);
|
|
414
|
-
return new Response(JSON.stringify({ object: "list", data }), {
|
|
415
|
-
headers: {
|
|
416
|
-
"content-type": "application/json",
|
|
417
|
-
"cache-control": "no-store",
|
|
418
|
-
"x-request-id": state.requestId,
|
|
419
|
-
"access-control-allow-origin": "*",
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
const handleModelsDetail = async ({
|
|
425
|
-
request,
|
|
426
|
-
params,
|
|
427
|
-
}: {
|
|
428
|
-
request: Request;
|
|
429
|
-
// Elysia omits `params` entirely for the literal `/models/info` route
|
|
430
|
-
// (it declares no path parameters), so this must stay optional.
|
|
431
|
-
params?: Record<string, string> | undefined;
|
|
432
|
-
}): Promise<Response> => {
|
|
433
|
-
const state = requestStateStore.require(request);
|
|
434
|
-
const auth = state.authorization;
|
|
435
|
-
if (!auth) throw modelsListApiKeyError();
|
|
436
|
-
const raw = params?.["*"] ?? params?.id ?? "";
|
|
437
|
-
const targetId = new URL(request.url).searchParams.get("id") ?? raw;
|
|
438
|
-
const found = await publicModelStore.getPublicModelDetail(
|
|
439
|
-
targetId,
|
|
440
|
-
auth.tenantId,
|
|
441
|
-
auth.snapshot,
|
|
442
|
-
auth.modelPrefix,
|
|
443
|
-
);
|
|
444
|
-
if (!found)
|
|
445
|
-
throw new GatewayError(
|
|
446
|
-
"model_not_found",
|
|
447
|
-
404,
|
|
448
|
-
`The model '${targetId}' does not exist or you do not have access to it.`,
|
|
449
|
-
);
|
|
450
|
-
return new Response(JSON.stringify(found), {
|
|
451
|
-
headers: {
|
|
452
|
-
"content-type": "application/json",
|
|
453
|
-
"cache-control": "no-store",
|
|
454
|
-
"x-request-id": state.requestId,
|
|
455
|
-
"access-control-allow-origin": "*",
|
|
456
|
-
},
|
|
457
|
-
});
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
const handleResponsesCompact = createResponsesCompactHandler({
|
|
461
|
-
db: deps.db,
|
|
462
|
-
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
463
|
-
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
464
|
-
proxyPreparer: deps.proxyPreparer,
|
|
465
|
-
stateStore: requestStateStore,
|
|
466
|
-
poolSelector: deps.poolSelector,
|
|
467
|
-
networkBindingFactory: deps.networkBindingFactory,
|
|
468
|
-
snapshotService: deps.snapshotService,
|
|
469
|
-
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
470
|
-
oauthRefreshService: deps.oauthRefreshService,
|
|
471
|
-
// The compact handler completes its attempt through `completeAttempt`,
|
|
472
|
-
// which marks the request completed and finalizes telemetry itself —
|
|
473
|
-
// so the afterResponse lifecycle skips it. Without the buffer here the
|
|
474
|
-
// route would emit no telemetry row at all.
|
|
475
|
-
telemetryBuffer: deps.telemetryBuffer,
|
|
476
|
-
});
|
|
477
|
-
|
|
478
|
-
const handleSystemone = createSystemoneHandler({
|
|
479
|
-
db: deps.db,
|
|
480
|
-
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
481
|
-
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
482
|
-
proxyPreparer: deps.proxyPreparer,
|
|
483
|
-
stateStore: requestStateStore,
|
|
484
|
-
poolSelector: deps.poolSelector,
|
|
485
|
-
networkBindingFactory: deps.networkBindingFactory,
|
|
486
|
-
snapshotService: deps.snapshotService,
|
|
487
|
-
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
488
|
-
oauthRefreshService: deps.oauthRefreshService,
|
|
489
|
-
// Same reasoning as compact: the handler finalizes telemetry itself, so
|
|
490
|
-
// the buffer must be threaded here or the route emits no row.
|
|
491
|
-
telemetryBuffer: deps.telemetryBuffer,
|
|
492
|
-
});
|
|
493
|
-
|
|
494
|
-
const handleWebsearch = createWebsearchHandler({
|
|
495
|
-
db: deps.db,
|
|
496
|
-
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
497
|
-
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
498
|
-
proxyPreparer: deps.proxyPreparer,
|
|
499
|
-
stateStore: requestStateStore,
|
|
500
|
-
poolSelector: deps.poolSelector,
|
|
501
|
-
networkBindingFactory: deps.networkBindingFactory,
|
|
502
|
-
snapshotService: deps.snapshotService,
|
|
503
|
-
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
504
|
-
oauthRefreshService: deps.oauthRefreshService,
|
|
505
|
-
telemetryBuffer: deps.telemetryBuffer,
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
const generationDeps = {
|
|
509
|
-
db: deps.db,
|
|
510
|
-
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
511
|
-
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
512
|
-
proxyPreparer: deps.proxyPreparer,
|
|
513
|
-
stateStore: requestStateStore,
|
|
514
|
-
poolSelector: deps.poolSelector,
|
|
515
|
-
networkBindingFactory: deps.networkBindingFactory,
|
|
516
|
-
snapshotService: deps.snapshotService,
|
|
517
|
-
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
518
|
-
oauthRefreshService: deps.oauthRefreshService,
|
|
519
|
-
telemetryBuffer: deps.telemetryBuffer,
|
|
520
|
-
};
|
|
521
|
-
const handleImageGeneration = createImageGenerationHandler(generationDeps);
|
|
522
|
-
// Mounted with no model behind it on purpose: the surface exists so it can
|
|
523
|
-
// be finished when an upstream is known, rather than 404ing like a typo.
|
|
524
|
-
const handleVideoGeneration = createVideoGenerationHandler(generationDeps);
|
|
525
|
-
// Passthrough surfaces: routed and accounted for, but never interpreted.
|
|
526
|
-
// No bundled adapter implements embeddings or audio, and inventing a wire
|
|
527
|
-
// for them would be a guess; what these need to be useful is the route, the
|
|
528
|
-
// pipeline, and a compatible upstream behind it.
|
|
529
|
-
const handleEmbedding = createEmbeddingHandler(generationDeps);
|
|
530
|
-
const handleAudioTranscription = createAudioTranscriptionHandler(generationDeps);
|
|
531
|
-
const handleAudioSpeech = createAudioSpeechHandler(generationDeps);
|
|
532
|
-
// Shares the generation dependency bundle: same pipeline, and a count is
|
|
533
|
-
// neither chat-shaped nor generation-shaped, so it needs exactly the same
|
|
534
|
-
// routing, leasing, and accounting surface.
|
|
535
|
-
const handleTokenCount = createTokenCountHandler(generationDeps);
|
|
536
|
-
|
|
537
|
-
// Gateway mounting: the pipeline owner composes stages, telemetry, and
|
|
538
|
-
// cleanup; app only registers the public route table.
|
|
539
|
-
app.use(
|
|
540
|
-
transportPipeline.createGateway((routes) => {
|
|
541
|
-
routes.post("/chat/completions", proxyHandler);
|
|
542
|
-
routes.post("/responses", proxyHandler);
|
|
543
|
-
routes.post("/responses/compact", handleResponsesCompact);
|
|
544
|
-
routes.post("/messages", proxyHandler);
|
|
545
|
-
// The token counter is a sibling of the chat route on the same model, and
|
|
546
|
-
// it is forwarded rather than estimated — see dispatch/token-count.ts.
|
|
547
|
-
routes.post("/messages/count_tokens", handleTokenCount);
|
|
548
|
-
routes.post("/completions", proxyHandler);
|
|
549
|
-
routes.post("/systemone", handleSystemone);
|
|
550
|
-
routes.post("/search", handleWebsearch);
|
|
551
|
-
routes.post("/images/generations", handleImageGeneration);
|
|
552
|
-
routes.post("/videos/generations", handleVideoGeneration);
|
|
553
|
-
routes.post("/embeddings", handleEmbedding);
|
|
554
|
-
routes.post("/audio/transcriptions", handleAudioTranscription);
|
|
555
|
-
routes.post("/audio/speech", handleAudioSpeech);
|
|
556
|
-
routes.get("/models", handleModelsList);
|
|
557
|
-
routes.get("/models/info", handleModelsDetail);
|
|
558
|
-
routes.get("/models/*", handleModelsDetail);
|
|
559
|
-
}),
|
|
560
|
-
);
|
|
561
|
-
|
|
562
|
-
// Operator drain endpoint. Registered before the SPA catch-all and only
|
|
563
|
-
// when a token is configured, so an unconfigured gateway has no such route
|
|
564
|
-
// at all. It is a graceful stop for platforms where a catchable signal
|
|
565
|
-
// cannot be delivered (Windows), and a second signal-free path elsewhere.
|
|
566
|
-
if (deps.drainToken && deps.triggerDrain) {
|
|
567
|
-
const drainHandler = createDrainHandler({
|
|
568
|
-
token: deps.drainToken,
|
|
569
|
-
triggerDrain: deps.triggerDrain,
|
|
570
|
-
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
571
|
-
});
|
|
572
|
-
app.post("/admin/drain", drainHandler);
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
// Operator backup export. Same double gate as drain and reset, and no
|
|
576
|
-
// console password: the CLI already runs as the OS user who owns the
|
|
577
|
-
// database, and a forgotten password must not block the backup that is
|
|
578
|
-
// meant to rescue the install.
|
|
579
|
-
if (deps.drainToken) {
|
|
580
|
-
app.post(
|
|
581
|
-
"/admin/export-backup",
|
|
582
|
-
createBackupExportHandler({
|
|
583
|
-
db: deps.db,
|
|
584
|
-
token: deps.drainToken,
|
|
585
|
-
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
586
|
-
resolveTenantId: async () => {
|
|
587
|
-
const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
|
|
588
|
-
return row?.id ?? null;
|
|
589
|
-
},
|
|
590
|
-
}),
|
|
591
|
-
);
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// Console account reset. Same double gate as drain — loopback plus the
|
|
595
|
-
// token — because it deletes every operator credential on the install.
|
|
596
|
-
// Only authentication state goes; providers, keys, and routing stay.
|
|
597
|
-
if (deps.drainToken) {
|
|
598
|
-
app.post(
|
|
599
|
-
"/admin/reset-console-account",
|
|
600
|
-
createConsoleAccountResetHandler({
|
|
601
|
-
token: deps.drainToken,
|
|
602
|
-
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
603
|
-
resetConsoleAccount: () => resetConsoleAuthState(deps.db),
|
|
604
|
-
}),
|
|
605
|
-
);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// Manual task trigger. Same gate as drain: loopback plus the drain token,
|
|
609
|
-
// so a leaked token still cannot reach it from outside the host. 202 once
|
|
610
|
-
// the run has started; 409 when that task is already mid-run, because the
|
|
611
|
-
// scheduler refuses to overlap a task with itself and a second trigger
|
|
612
|
-
// would otherwise look like it ran.
|
|
613
|
-
if (deps.drainToken && deps.scheduledTasks) {
|
|
614
|
-
const tasks = deps.scheduledTasks;
|
|
615
|
-
const token = deps.drainToken;
|
|
616
|
-
app.post("/admin/tasks/:name/run", async ({ request, params }) => {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
app.all("/
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
app.all("
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
1
|
+
import { Elysia } from "elysia";
|
|
2
|
+
import { resolveDashboardDist, resolveElysiaPrecompile } from "../config/runtime";
|
|
3
|
+
import { createConsoleRouter, type ConsoleApiCompositionDeps } from "../console/console-router";
|
|
4
|
+
import type { ScheduledTaskRegistry } from "../workers/scheduler";
|
|
5
|
+
import { createStaticHandler } from "../console/dashboard-assets";
|
|
6
|
+
import { createShareRouter } from "../console/share/share-router";
|
|
7
|
+
import { createShareStatsPort } from "../console/share/share-stats";
|
|
8
|
+
import { DrizzleShareLinkStore } from "../persistence/stores/share-store";
|
|
9
|
+
import { chatAdapter } from "../transport/surface/chat/adapter";
|
|
10
|
+
import { responsesAdapter } from "../transport/surface/responses/adapter";
|
|
11
|
+
import { messagesAdapter } from "../transport/surface/messages/adapter";
|
|
12
|
+
import { completionAdapter } from "../transport/surface/completion";
|
|
13
|
+
import { SurfaceAdapterRegistry } from "../transport/surface/adapters";
|
|
14
|
+
import { GatewayError, publicGatewayErrorBody } from "../protocol/gateway-error";
|
|
15
|
+
import { shutdownError, shutdownNotice } from "../transport/shutdown-notice";
|
|
16
|
+
import type { CanonicalAdapter } from "../transport/contracts";
|
|
17
|
+
import type { ApiKeyAuthorizationSnapshot } from "../security/api-key-auth";
|
|
18
|
+
import { modelsListApiKeyError } from "../security/api-key-auth";
|
|
19
|
+
import { getDbHandle } from "../persistence/boot";
|
|
20
|
+
import { isPgHandle } from "../persistence/db-pg";
|
|
21
|
+
import { resolveRedisClient } from "../persistence/redis";
|
|
22
|
+
import type { CartethyiaDatabase } from "../persistence/db-handle";
|
|
23
|
+
import type { ProviderAdapter } from "../providers/provider-contracts";
|
|
24
|
+
import type { OAuthTokenRefresher, OAuthRefreshService } from "../providers/authentication/oauth-refresh-service";
|
|
25
|
+
import { withTimeout } from "./timeout";
|
|
26
|
+
import type { ByokUpstreamHost } from "../providers/operations/provider-catalog-service";
|
|
27
|
+
import type { NetworkPoolSelector } from "../network/pool/selector";
|
|
28
|
+
import type { ProxyRequestPreparer } from "../transport/request/preparer";
|
|
29
|
+
import { ProxyRequestStateStore } from "../transport/request/state";
|
|
30
|
+
import { fastPathname } from "../transport/pathname";
|
|
31
|
+
import {
|
|
32
|
+
handleProviderProxyRequest,
|
|
33
|
+
type ProviderProxyHandlerDeps,
|
|
34
|
+
} from "../transport/dispatch/proxy-request";
|
|
35
|
+
import { createResponsesCompactHandler } from "../transport/dispatch/responses-compact";
|
|
36
|
+
import { createSystemoneHandler } from "../transport/dispatch/systemone";
|
|
37
|
+
import { createWebsearchHandler } from "../transport/dispatch/websearch";
|
|
38
|
+
import {
|
|
39
|
+
createAudioSpeechHandler,
|
|
40
|
+
createAudioTranscriptionHandler,
|
|
41
|
+
createEmbeddingHandler,
|
|
42
|
+
createImageGenerationHandler,
|
|
43
|
+
createVideoGenerationHandler,
|
|
44
|
+
} from "../transport/dispatch/generation";
|
|
45
|
+
import { createTokenCountHandler } from "../transport/dispatch/token-count";
|
|
46
|
+
|
|
47
|
+
import { createTransportPipeline } from "../transport/guards/pipeline";
|
|
48
|
+
import { createDrainHandler, isLoopbackPeer, tokenMatches } from "../transport/drain-endpoint";
|
|
49
|
+
import { createConsoleAccountResetHandler } from "../console/auth/account-reset";
|
|
50
|
+
import { resetConsoleAuthState } from "../console/auth/account-reset-store";
|
|
51
|
+
import { createBackupExportHandler } from "../console/backup/export-endpoint";
|
|
52
|
+
import { tenants } from "../persistence/schema";
|
|
53
|
+
import { PublicModelCatalogStore, type AllowedModelEntry } from "../console/providers/catalog/public-model-store";
|
|
54
|
+
import type { IpAbuseProtectionService } from "../security/abuse";
|
|
55
|
+
import type { ReadinessCheckResult } from "../persistence/readiness";
|
|
56
|
+
import type { RouteSnapshotService } from "../transport/routing/route-model";
|
|
57
|
+
import type { TrustedProxyBoundary } from "../config/network";
|
|
58
|
+
import { resolveClientIdentity } from "../security/ip-boundary";
|
|
59
|
+
import type { ValidatedNetworkBindingFactory } from "../network/pool/resolver";
|
|
60
|
+
import { metrics } from "../observability/metrics";
|
|
61
|
+
import type { RuntimeMetricsSampler } from "../observability/runtime-metrics";
|
|
62
|
+
import { API_CONTENT_SECURITY_POLICY, X_FRAME_OPTIONS } from "../security/outbound-headers";
|
|
63
|
+
import type { ModelStrikeService } from "../security/model-abuse";
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
import type { TelemetryBatchBuffer } from "../observability/telemetry-buffer";
|
|
67
|
+
import type { ShutdownCoordinator } from "./lifecycle";
|
|
68
|
+
import type { ShutdownDrainSource } from "../transport/contracts";
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Route-only shell dependencies: the dashboard, `/health`, and `/metrics`
|
|
72
|
+
* without the transport pipeline. Used when no database is available (AOT
|
|
73
|
+
* manifest capture during `bun run build:aot`) and by tests that exercise
|
|
74
|
+
* routing and static serving in isolation.
|
|
75
|
+
*
|
|
76
|
+
* Only the knobs route-only mode actually reads appear here. Transport and
|
|
77
|
+
* console options (`db`, `resolvePeerAddress`, `trustedProxyBoundary`,
|
|
78
|
+
* `maxBodyBytes`, `requestDeadlineMs`, `verifiedHttps`) are deliberately
|
|
79
|
+
* absent: they are read inside the `mode === "production"` branches, where the
|
|
80
|
+
* discriminant narrows `deps` to `ProductionAppDeps`. Listing them here would
|
|
81
|
+
* let a caller pass a value the shell silently ignores.
|
|
82
|
+
*/
|
|
83
|
+
interface GatewayShellDeps {
|
|
84
|
+
readonly mode: "route-only";
|
|
85
|
+
readonly dashboardDist?: string;
|
|
86
|
+
readonly checkDb?: () => Promise<void>;
|
|
87
|
+
readonly checkRedis?: () => Promise<void>;
|
|
88
|
+
readonly readiness?: () => Promise<ReadinessCheckResult>;
|
|
89
|
+
/**
|
|
90
|
+
* Drain state the readiness route reads. The shell has no coordinator, so it
|
|
91
|
+
* takes the narrow read surface (`ShutdownDrainSource`) rather than a
|
|
92
|
+
* hand-maintained copy of the whole `ShutdownCoordinator`.
|
|
93
|
+
*/
|
|
94
|
+
readonly shutdownCoordinator?: ShutdownDrainSource;
|
|
95
|
+
/**
|
|
96
|
+
* Memory sampler when the caller owns one. The shell never constructs it, so
|
|
97
|
+
* it stays optional; production always supplies it and binds its in-flight
|
|
98
|
+
* source to the request store below.
|
|
99
|
+
*/
|
|
100
|
+
readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Full production dependencies. Every transport, pool, snapshot, telemetry,
|
|
105
|
+
* and console field is required — the composition root in
|
|
106
|
+
* `runtime/dependencies.ts` either supplies it or the app does not boot, so
|
|
107
|
+
* there is no runtime shape check to fall through.
|
|
108
|
+
*/
|
|
109
|
+
export interface ProductionAppDeps {
|
|
110
|
+
readonly mode: "production";
|
|
111
|
+
readonly db: CartethyiaDatabase;
|
|
112
|
+
readonly proxyPreparer: ProxyRequestPreparer;
|
|
113
|
+
readonly resolveProviderAdapter: (providerId: string) => Promise<ProviderAdapter | undefined>;
|
|
114
|
+
readonly providerAdapters?: ReadonlyMap<string, ProviderAdapter>;
|
|
115
|
+
/** Live lookup of a provider's SSRF-validated upstream host. */
|
|
116
|
+
readonly byokUpstreamHosts: { readonly get: (providerId: string) => ByokUpstreamHost | undefined };
|
|
117
|
+
readonly networkBindingFactory: ValidatedNetworkBindingFactory;
|
|
118
|
+
readonly ipAbuseProtection: IpAbuseProtectionService;
|
|
119
|
+
readonly trustedProxyBoundary: TrustedProxyBoundary;
|
|
120
|
+
readonly poolSelector: NetworkPoolSelector;
|
|
121
|
+
readonly snapshotService: RouteSnapshotService;
|
|
122
|
+
readonly readiness: () => Promise<ReadinessCheckResult>;
|
|
123
|
+
readonly telemetryBuffer: TelemetryBatchBuffer;
|
|
124
|
+
readonly resolveOAuthRefresher: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
|
|
125
|
+
readonly oauthRefreshService: OAuthRefreshService;
|
|
126
|
+
/** Graduated strikes for repeated invalid-model requests. */
|
|
127
|
+
readonly modelStrikes?: ModelStrikeService;
|
|
128
|
+
/**
|
|
129
|
+
* The console control plane. Optional so reduced compositions (route-only
|
|
130
|
+
* shell, console stubs) stay valid; production always mounts it. Without
|
|
131
|
+
* REDIS_URL the console runs on the in-memory backend like the data plane
|
|
132
|
+
* (OAuth-flow state and the quota cache fall back to process memory;
|
|
133
|
+
* sessions live in `console_sessions` in Postgres and CSRF is stateless).
|
|
134
|
+
*/
|
|
135
|
+
readonly consoleApi?: ConsoleApiCompositionDeps;
|
|
136
|
+
/**
|
|
137
|
+
* The shared maintenance scheduler. The in-flight backstop sweep registers
|
|
138
|
+
* here rather than owning a private timer, so every periodic task in the
|
|
139
|
+
* process is visible in one place. Absent in reduced compositions, where the
|
|
140
|
+
* backstop is not mounted.
|
|
141
|
+
*/
|
|
142
|
+
readonly scheduledTasks?: ScheduledTaskRegistry;
|
|
143
|
+
/** Memory sampler; bound to the request store created below. */
|
|
144
|
+
readonly runtimeMetricsSampler?: RuntimeMetricsSampler;
|
|
145
|
+
/** The real coordinator: production wires its abort hook and tracks requests. */
|
|
146
|
+
readonly shutdownCoordinator: ShutdownCoordinator;
|
|
147
|
+
readonly dashboardDist?: string;
|
|
148
|
+
readonly resolvePeerAddress?: (request: Request) => string | null;
|
|
149
|
+
readonly maxBodyBytes?: number;
|
|
150
|
+
readonly requestDeadlineMs?: number;
|
|
151
|
+
readonly verifiedHttps?: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Secret for the operator drain endpoint. When set (with `triggerDrain`),
|
|
154
|
+
* `POST /admin/drain` from loopback with a matching `x-drain-token` drains
|
|
155
|
+
* gracefully — the signal-free stop Windows needs.
|
|
156
|
+
*/
|
|
157
|
+
readonly drainToken?: string;
|
|
158
|
+
readonly triggerDrain?: () => void;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Either app mode. The discriminant decides which builder path runs. */
|
|
162
|
+
type GatewayAppDeps = GatewayShellDeps | ProductionAppDeps;
|
|
163
|
+
|
|
164
|
+
function checkDatabaseConnection(): Promise<void> {
|
|
165
|
+
return getDbHandle()
|
|
166
|
+
.query("SELECT 1")
|
|
167
|
+
.then(() => undefined);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function checkRedisConnection(): Promise<void> {
|
|
171
|
+
// Memory backend is healthy by construction — there is no connection to
|
|
172
|
+
// probe — so readiness only pings a real client.
|
|
173
|
+
const redis = resolveRedisClient();
|
|
174
|
+
if (!redis) return Promise.resolve();
|
|
175
|
+
return redis.ping().then(() => undefined);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runDependencyReadinessCheck(check: () => Promise<void>, label: string): Promise<void> {
|
|
179
|
+
return withTimeout(Promise.resolve().then(check), 2000, `${label} timeout after 2000ms`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Builds the public HTTP composition root with route ordering guarantee.
|
|
184
|
+
* The one builder behind both modes: `deps.mode` selects which planes mount,
|
|
185
|
+
* and each mode's dependency interface admits no partial set.
|
|
186
|
+
*/
|
|
187
|
+
export function createGatewayApp(deps: GatewayAppDeps) {
|
|
188
|
+
const dbCheck =
|
|
189
|
+
deps.mode === "route-only"
|
|
190
|
+
? (deps.checkDb ?? checkDatabaseConnection)
|
|
191
|
+
: checkDatabaseConnection;
|
|
192
|
+
const redisCheck =
|
|
193
|
+
deps.mode === "route-only"
|
|
194
|
+
? (deps.checkRedis ?? checkRedisConnection)
|
|
195
|
+
: checkRedisConnection;
|
|
196
|
+
const registry = new SurfaceAdapterRegistry([
|
|
197
|
+
chatAdapter,
|
|
198
|
+
responsesAdapter,
|
|
199
|
+
messagesAdapter,
|
|
200
|
+
completionAdapter,
|
|
201
|
+
]);
|
|
202
|
+
const dashboardDist = deps.dashboardDist ?? resolveDashboardDist();
|
|
203
|
+
const staticHandler = createStaticHandler({ buildDir: dashboardDist });
|
|
204
|
+
const peerAddresses = new WeakMap<Request, string>();
|
|
205
|
+
const serveDashboard = async ({ request }: { request: Request }): Promise<Response> => {
|
|
206
|
+
const pathname = fastPathname(request.url);
|
|
207
|
+
// The client's `accept-encoding` is what decides whether a precompressed
|
|
208
|
+
// `.gz` sibling can be served instead of the plain asset.
|
|
209
|
+
const result = await staticHandler(pathname, request.headers.get("accept-encoding"));
|
|
210
|
+
return new Response(result.body ? (result.body as unknown as BodyInit) : null, {
|
|
211
|
+
status: result.status,
|
|
212
|
+
headers: result.headers,
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Root catch-all. Non-document namespaces (`/v1`, `/health`, `/metrics`) keep
|
|
217
|
+
* their JSON 404 so an unknown API path can never be answered with the SPA
|
|
218
|
+
* document; everything else resolves through the static handler.
|
|
219
|
+
*/
|
|
220
|
+
const API_NAMESPACES = ["/v1", "/health", "/metrics"] as const;
|
|
221
|
+
const serveRootFallback = async ({ request }: { request: Request }): Promise<Response> => {
|
|
222
|
+
const pathname = fastPathname(request.url);
|
|
223
|
+
const reserved = API_NAMESPACES.some(
|
|
224
|
+
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
|
|
225
|
+
);
|
|
226
|
+
if (reserved) {
|
|
227
|
+
const notFound = new GatewayError("not_found", 404, "No endpoint exists at this path. Check the route against the API reference.");
|
|
228
|
+
return new Response(JSON.stringify(publicGatewayErrorBody(notFound)), {
|
|
229
|
+
status: notFound.status,
|
|
230
|
+
headers: {
|
|
231
|
+
"content-type": "application/json; charset=utf-8",
|
|
232
|
+
"cache-control": "no-store",
|
|
233
|
+
"content-security-policy": API_CONTENT_SECURITY_POLICY,
|
|
234
|
+
"x-frame-options": X_FRAME_OPTIONS,
|
|
235
|
+
"x-content-type-options": "nosniff",
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
return serveDashboard({ request });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
// Build the Elysia app with strict route ordering.
|
|
243
|
+
const requestStateStore = new ProxyRequestStateStore(
|
|
244
|
+
deps.mode === "production" ? deps.shutdownCoordinator : undefined,
|
|
245
|
+
);
|
|
246
|
+
deps.runtimeMetricsSampler?.bindInFlight(() => requestStateStore.inFlightCount());
|
|
247
|
+
// Shutdown ordering: abort in-flight proxy controllers first so the
|
|
248
|
+
// bounded drain observes cancellation and finalizers run before
|
|
249
|
+
// telemetry flush and pool close. The reason is threaded through so the
|
|
250
|
+
// streaming path emits a drain-aware terminal frame (`restart_for_update`
|
|
251
|
+
// vs `shutting_down`) instead of a bare abort that reads as a client drop.
|
|
252
|
+
// Only production has a coordinator to wire; the shell never drains.
|
|
253
|
+
if (deps.mode === "production") {
|
|
254
|
+
const coordinator = deps.shutdownCoordinator;
|
|
255
|
+
coordinator.setAbortInflight(() =>
|
|
256
|
+
requestStateStore.abortAll(shutdownError(coordinator.shutdownReason())),
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
// Backstop: the gauge must settle even if an abort path never fires (a
|
|
260
|
+
// half-closed socket that neither pulls, cancels, nor aborts). This sweep
|
|
261
|
+
// force-releases any flight past its deadline plus a grace window, so a
|
|
262
|
+
// missed release cannot leave a permanently wrong number. The grace is a
|
|
263
|
+
// safety margin over the request deadline, not a deployment knob.
|
|
264
|
+
const INFLIGHT_BACKSTOP_INTERVAL_MS = 30_000;
|
|
265
|
+
const INFLIGHT_BACKSTOP_GRACE_MS = 30_000;
|
|
266
|
+
const INFLIGHT_BACKSTOP_HARD_GRACE_MS = 120_000;
|
|
267
|
+
if (deps.mode === "production") deps.scheduledTasks?.register({
|
|
268
|
+
name: "inflight-backstop",
|
|
269
|
+
intervalMs: INFLIGHT_BACKSTOP_INTERVAL_MS,
|
|
270
|
+
run: () => {
|
|
271
|
+
requestStateStore.sweepOverdueInFlight(
|
|
272
|
+
Date.now(),
|
|
273
|
+
INFLIGHT_BACKSTOP_GRACE_MS,
|
|
274
|
+
INFLIGHT_BACKSTOP_HARD_GRACE_MS,
|
|
275
|
+
);
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
const app = new Elysia({ precompile: resolveElysiaPrecompile() })
|
|
279
|
+
.beforeHandle(
|
|
280
|
+
({
|
|
281
|
+
request,
|
|
282
|
+
server,
|
|
283
|
+
}: {
|
|
284
|
+
request: Request;
|
|
285
|
+
server?: { requestIP(request: Request): { address: string } | null } | null;
|
|
286
|
+
}) => {
|
|
287
|
+
const address = server?.requestIP(request)?.address;
|
|
288
|
+
if (address) peerAddresses.set(request, address);
|
|
289
|
+
},
|
|
290
|
+
)
|
|
291
|
+
.get("/health", () => ({ status: "ok" as const }))
|
|
292
|
+
.get("/health/ready", async () => {
|
|
293
|
+
// A draining process reports not-ready so the orchestrator stops
|
|
294
|
+
// sending it traffic and the replacement takes over without the old
|
|
295
|
+
// one crashing first: SIGTERM → draining → 503 here → Docker routes
|
|
296
|
+
// to the new container → old one finishes in flight and exits 0.
|
|
297
|
+
// The reason distinguishes an ordinary stop from an in-place update,
|
|
298
|
+
// whose replacement is seconds away, so a probe consumer can back off
|
|
299
|
+
// briefly instead of treating the drain as a permanent outage.
|
|
300
|
+
if (deps.shutdownCoordinator?.isDraining()) {
|
|
301
|
+
const notice = shutdownNotice(deps.shutdownCoordinator.shutdownReason?.());
|
|
302
|
+
return new Response(JSON.stringify({ status: "not_ready" as const, reason: notice.code }), {
|
|
303
|
+
status: 503,
|
|
304
|
+
headers: { "content-type": "application/json" },
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
const readiness = deps.readiness ? await deps.readiness() : undefined;
|
|
308
|
+
const results = readiness
|
|
309
|
+
? [
|
|
310
|
+
{ status: readiness.db === "connected" ? "fulfilled" : "rejected" },
|
|
311
|
+
{ status: readiness.redis !== "disconnected" ? "fulfilled" : "rejected" },
|
|
312
|
+
]
|
|
313
|
+
: await Promise.allSettled([
|
|
314
|
+
runDependencyReadinessCheck(dbCheck, "db"),
|
|
315
|
+
runDependencyReadinessCheck(redisCheck, "redis"),
|
|
316
|
+
]);
|
|
317
|
+
const dbOk = results[0]?.status === "fulfilled";
|
|
318
|
+
const redisOk = results[1]?.status === "fulfilled";
|
|
319
|
+
const migrationsOk = readiness ? readiness.migrations === "applied" : true;
|
|
320
|
+
|
|
321
|
+
if (dbOk && redisOk && migrationsOk) {
|
|
322
|
+
return {
|
|
323
|
+
status: "ready" as const,
|
|
324
|
+
db: "connected" as const,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return new Response(
|
|
329
|
+
JSON.stringify({
|
|
330
|
+
status: "not_ready" as const,
|
|
331
|
+
db: dbOk ? ("connected" as const) : ("disconnected" as const),
|
|
332
|
+
redis: redisOk ? ("connected" as const) : ("disconnected" as const),
|
|
333
|
+
...(readiness ? { migrations: readiness.migrations } : {}),
|
|
334
|
+
reason: !migrationsOk
|
|
335
|
+
? "database migrations pending"
|
|
336
|
+
: "dependency not ready",
|
|
337
|
+
}),
|
|
338
|
+
{ status: 503, headers: { "content-type": "application/json" } },
|
|
339
|
+
);
|
|
340
|
+
})
|
|
341
|
+
.get("/metrics", () => {
|
|
342
|
+
const handle = getDbHandle();
|
|
343
|
+
// Pool gauges describe the external server's connection budget; the
|
|
344
|
+
// embedded backend has no pool to report.
|
|
345
|
+
if (isPgHandle(handle)) {
|
|
346
|
+
metrics.cartethyia_pg_pool_total.set(handle.pool.totalCount);
|
|
347
|
+
metrics.cartethyia_pg_pool_idle.set(handle.pool.idleCount);
|
|
348
|
+
metrics.cartethyia_pg_pool_waiting.set(handle.pool.waitingCount);
|
|
349
|
+
}
|
|
350
|
+
// No client means the memory backend, which is always up; a real client
|
|
351
|
+
// reports its own readiness so the scrape never throws.
|
|
352
|
+
const redisClient = resolveRedisClient();
|
|
353
|
+
metrics.cartethyia_redis_up.set(redisClient === undefined || redisClient.status === "ready" ? 1 : 0);
|
|
354
|
+
return new Response(metrics.render(), {
|
|
355
|
+
headers: { "content-type": "text/plain; version=0.0.4" },
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (deps.mode === "production") {
|
|
360
|
+
const transportPipeline = createTransportPipeline({
|
|
361
|
+
db: deps.db,
|
|
362
|
+
stateStore: requestStateStore,
|
|
363
|
+
surfaceRegistry: registry,
|
|
364
|
+
adapters: new Map<string, CanonicalAdapter>([
|
|
365
|
+
["chat", chatAdapter],
|
|
366
|
+
["responses", responsesAdapter],
|
|
367
|
+
["messages", messagesAdapter],
|
|
368
|
+
["completion", completionAdapter],
|
|
369
|
+
]),
|
|
370
|
+
preparer: deps.proxyPreparer,
|
|
371
|
+
readiness: deps.readiness,
|
|
372
|
+
trustedProxyBoundary: deps.trustedProxyBoundary,
|
|
373
|
+
...(deps.resolvePeerAddress ? { resolvePeerAddress: deps.resolvePeerAddress } : {}),
|
|
374
|
+
ipAbuseProtection: deps.ipAbuseProtection,
|
|
375
|
+
...(deps.maxBodyBytes === undefined ? {} : { maxBodyBytes: deps.maxBodyBytes }),
|
|
376
|
+
...(deps.requestDeadlineMs === undefined ? {} : { requestDeadlineMs: deps.requestDeadlineMs }),
|
|
377
|
+
...(deps.verifiedHttps ? { verifiedHttps: true } : {}),
|
|
378
|
+
shutdownCoordinator: deps.shutdownCoordinator,
|
|
379
|
+
telemetry: deps.telemetryBuffer,
|
|
380
|
+
...(deps.modelStrikes ? { modelStrikes: deps.modelStrikes } : {}),
|
|
381
|
+
...(deps.consoleApi?.redis ? { redis: deps.consoleApi.redis } : {}),
|
|
382
|
+
});
|
|
383
|
+
transportPipeline.mountRoot(app);
|
|
384
|
+
const proxyDeps: ProviderProxyHandlerDeps = {
|
|
385
|
+
db: deps.db,
|
|
386
|
+
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
387
|
+
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
388
|
+
stateStore: requestStateStore,
|
|
389
|
+
proxyPreparer: deps.proxyPreparer,
|
|
390
|
+
snapshotService: deps.snapshotService,
|
|
391
|
+
networkBindingFactory: deps.networkBindingFactory,
|
|
392
|
+
byokUpstreamHosts: deps.byokUpstreamHosts,
|
|
393
|
+
poolSelector: deps.poolSelector,
|
|
394
|
+
telemetryBuffer: deps.telemetryBuffer,
|
|
395
|
+
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
396
|
+
oauthRefreshService: deps.oauthRefreshService,
|
|
397
|
+
};
|
|
398
|
+
const proxyHandler = ({ request }: { request: Request }) =>
|
|
399
|
+
handleProviderProxyRequest(request, proxyDeps);
|
|
400
|
+
|
|
401
|
+
const publicModelStore = new PublicModelCatalogStore(deps.db);
|
|
402
|
+
const queryAllowedModels = (auth: {
|
|
403
|
+
snapshot: ApiKeyAuthorizationSnapshot;
|
|
404
|
+
tenantId: string | null;
|
|
405
|
+
modelPrefix?: string;
|
|
406
|
+
}): Promise<AllowedModelEntry[]> =>
|
|
407
|
+
publicModelStore.listPublicModels(auth.tenantId, auth.snapshot, auth.modelPrefix);
|
|
408
|
+
|
|
409
|
+
const handleModelsList = async ({ request }: { request: Request }): Promise<Response> => {
|
|
410
|
+
const state = requestStateStore.require(request);
|
|
411
|
+
const auth = state.authorization;
|
|
412
|
+
if (!auth) throw modelsListApiKeyError();
|
|
413
|
+
const data = await queryAllowedModels(auth);
|
|
414
|
+
return new Response(JSON.stringify({ object: "list", data }), {
|
|
415
|
+
headers: {
|
|
416
|
+
"content-type": "application/json",
|
|
417
|
+
"cache-control": "no-store",
|
|
418
|
+
"x-request-id": state.requestId,
|
|
419
|
+
"access-control-allow-origin": "*",
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
const handleModelsDetail = async ({
|
|
425
|
+
request,
|
|
426
|
+
params,
|
|
427
|
+
}: {
|
|
428
|
+
request: Request;
|
|
429
|
+
// Elysia omits `params` entirely for the literal `/models/info` route
|
|
430
|
+
// (it declares no path parameters), so this must stay optional.
|
|
431
|
+
params?: Record<string, string> | undefined;
|
|
432
|
+
}): Promise<Response> => {
|
|
433
|
+
const state = requestStateStore.require(request);
|
|
434
|
+
const auth = state.authorization;
|
|
435
|
+
if (!auth) throw modelsListApiKeyError();
|
|
436
|
+
const raw = params?.["*"] ?? params?.id ?? "";
|
|
437
|
+
const targetId = new URL(request.url).searchParams.get("id") ?? raw;
|
|
438
|
+
const found = await publicModelStore.getPublicModelDetail(
|
|
439
|
+
targetId,
|
|
440
|
+
auth.tenantId,
|
|
441
|
+
auth.snapshot,
|
|
442
|
+
auth.modelPrefix,
|
|
443
|
+
);
|
|
444
|
+
if (!found)
|
|
445
|
+
throw new GatewayError(
|
|
446
|
+
"model_not_found",
|
|
447
|
+
404,
|
|
448
|
+
`The model '${targetId}' does not exist or you do not have access to it.`,
|
|
449
|
+
);
|
|
450
|
+
return new Response(JSON.stringify(found), {
|
|
451
|
+
headers: {
|
|
452
|
+
"content-type": "application/json",
|
|
453
|
+
"cache-control": "no-store",
|
|
454
|
+
"x-request-id": state.requestId,
|
|
455
|
+
"access-control-allow-origin": "*",
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const handleResponsesCompact = createResponsesCompactHandler({
|
|
461
|
+
db: deps.db,
|
|
462
|
+
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
463
|
+
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
464
|
+
proxyPreparer: deps.proxyPreparer,
|
|
465
|
+
stateStore: requestStateStore,
|
|
466
|
+
poolSelector: deps.poolSelector,
|
|
467
|
+
networkBindingFactory: deps.networkBindingFactory,
|
|
468
|
+
snapshotService: deps.snapshotService,
|
|
469
|
+
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
470
|
+
oauthRefreshService: deps.oauthRefreshService,
|
|
471
|
+
// The compact handler completes its attempt through `completeAttempt`,
|
|
472
|
+
// which marks the request completed and finalizes telemetry itself —
|
|
473
|
+
// so the afterResponse lifecycle skips it. Without the buffer here the
|
|
474
|
+
// route would emit no telemetry row at all.
|
|
475
|
+
telemetryBuffer: deps.telemetryBuffer,
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
const handleSystemone = createSystemoneHandler({
|
|
479
|
+
db: deps.db,
|
|
480
|
+
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
481
|
+
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
482
|
+
proxyPreparer: deps.proxyPreparer,
|
|
483
|
+
stateStore: requestStateStore,
|
|
484
|
+
poolSelector: deps.poolSelector,
|
|
485
|
+
networkBindingFactory: deps.networkBindingFactory,
|
|
486
|
+
snapshotService: deps.snapshotService,
|
|
487
|
+
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
488
|
+
oauthRefreshService: deps.oauthRefreshService,
|
|
489
|
+
// Same reasoning as compact: the handler finalizes telemetry itself, so
|
|
490
|
+
// the buffer must be threaded here or the route emits no row.
|
|
491
|
+
telemetryBuffer: deps.telemetryBuffer,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const handleWebsearch = createWebsearchHandler({
|
|
495
|
+
db: deps.db,
|
|
496
|
+
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
497
|
+
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
498
|
+
proxyPreparer: deps.proxyPreparer,
|
|
499
|
+
stateStore: requestStateStore,
|
|
500
|
+
poolSelector: deps.poolSelector,
|
|
501
|
+
networkBindingFactory: deps.networkBindingFactory,
|
|
502
|
+
snapshotService: deps.snapshotService,
|
|
503
|
+
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
504
|
+
oauthRefreshService: deps.oauthRefreshService,
|
|
505
|
+
telemetryBuffer: deps.telemetryBuffer,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const generationDeps = {
|
|
509
|
+
db: deps.db,
|
|
510
|
+
providerAdapters: deps.providerAdapters ?? new Map(),
|
|
511
|
+
resolveProviderAdapter: deps.resolveProviderAdapter,
|
|
512
|
+
proxyPreparer: deps.proxyPreparer,
|
|
513
|
+
stateStore: requestStateStore,
|
|
514
|
+
poolSelector: deps.poolSelector,
|
|
515
|
+
networkBindingFactory: deps.networkBindingFactory,
|
|
516
|
+
snapshotService: deps.snapshotService,
|
|
517
|
+
resolveOAuthRefresher: deps.resolveOAuthRefresher,
|
|
518
|
+
oauthRefreshService: deps.oauthRefreshService,
|
|
519
|
+
telemetryBuffer: deps.telemetryBuffer,
|
|
520
|
+
};
|
|
521
|
+
const handleImageGeneration = createImageGenerationHandler(generationDeps);
|
|
522
|
+
// Mounted with no model behind it on purpose: the surface exists so it can
|
|
523
|
+
// be finished when an upstream is known, rather than 404ing like a typo.
|
|
524
|
+
const handleVideoGeneration = createVideoGenerationHandler(generationDeps);
|
|
525
|
+
// Passthrough surfaces: routed and accounted for, but never interpreted.
|
|
526
|
+
// No bundled adapter implements embeddings or audio, and inventing a wire
|
|
527
|
+
// for them would be a guess; what these need to be useful is the route, the
|
|
528
|
+
// pipeline, and a compatible upstream behind it.
|
|
529
|
+
const handleEmbedding = createEmbeddingHandler(generationDeps);
|
|
530
|
+
const handleAudioTranscription = createAudioTranscriptionHandler(generationDeps);
|
|
531
|
+
const handleAudioSpeech = createAudioSpeechHandler(generationDeps);
|
|
532
|
+
// Shares the generation dependency bundle: same pipeline, and a count is
|
|
533
|
+
// neither chat-shaped nor generation-shaped, so it needs exactly the same
|
|
534
|
+
// routing, leasing, and accounting surface.
|
|
535
|
+
const handleTokenCount = createTokenCountHandler(generationDeps);
|
|
536
|
+
|
|
537
|
+
// Gateway mounting: the pipeline owner composes stages, telemetry, and
|
|
538
|
+
// cleanup; app only registers the public route table.
|
|
539
|
+
app.use(
|
|
540
|
+
transportPipeline.createGateway((routes) => {
|
|
541
|
+
routes.post("/chat/completions", proxyHandler);
|
|
542
|
+
routes.post("/responses", proxyHandler);
|
|
543
|
+
routes.post("/responses/compact", handleResponsesCompact);
|
|
544
|
+
routes.post("/messages", proxyHandler);
|
|
545
|
+
// The token counter is a sibling of the chat route on the same model, and
|
|
546
|
+
// it is forwarded rather than estimated — see dispatch/token-count.ts.
|
|
547
|
+
routes.post("/messages/count_tokens", handleTokenCount);
|
|
548
|
+
routes.post("/completions", proxyHandler);
|
|
549
|
+
routes.post("/systemone", handleSystemone);
|
|
550
|
+
routes.post("/search", handleWebsearch);
|
|
551
|
+
routes.post("/images/generations", handleImageGeneration);
|
|
552
|
+
routes.post("/videos/generations", handleVideoGeneration);
|
|
553
|
+
routes.post("/embeddings", handleEmbedding);
|
|
554
|
+
routes.post("/audio/transcriptions", handleAudioTranscription);
|
|
555
|
+
routes.post("/audio/speech", handleAudioSpeech);
|
|
556
|
+
routes.get("/models", handleModelsList);
|
|
557
|
+
routes.get("/models/info", handleModelsDetail);
|
|
558
|
+
routes.get("/models/*", handleModelsDetail);
|
|
559
|
+
}),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// Operator drain endpoint. Registered before the SPA catch-all and only
|
|
563
|
+
// when a token is configured, so an unconfigured gateway has no such route
|
|
564
|
+
// at all. It is a graceful stop for platforms where a catchable signal
|
|
565
|
+
// cannot be delivered (Windows), and a second signal-free path elsewhere.
|
|
566
|
+
if (deps.drainToken && deps.triggerDrain) {
|
|
567
|
+
const drainHandler = createDrainHandler({
|
|
568
|
+
token: deps.drainToken,
|
|
569
|
+
triggerDrain: deps.triggerDrain,
|
|
570
|
+
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
571
|
+
});
|
|
572
|
+
app.post("/admin/drain", drainHandler);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Operator backup export. Same double gate as drain and reset, and no
|
|
576
|
+
// console password: the CLI already runs as the OS user who owns the
|
|
577
|
+
// database, and a forgotten password must not block the backup that is
|
|
578
|
+
// meant to rescue the install.
|
|
579
|
+
if (deps.drainToken) {
|
|
580
|
+
app.post(
|
|
581
|
+
"/admin/export-backup",
|
|
582
|
+
createBackupExportHandler({
|
|
583
|
+
db: deps.db,
|
|
584
|
+
token: deps.drainToken,
|
|
585
|
+
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
586
|
+
resolveTenantId: async () => {
|
|
587
|
+
const [row] = await deps.db.select({ id: tenants.id }).from(tenants).limit(1);
|
|
588
|
+
return row?.id ?? null;
|
|
589
|
+
},
|
|
590
|
+
}),
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Console account reset. Same double gate as drain — loopback plus the
|
|
595
|
+
// token — because it deletes every operator credential on the install.
|
|
596
|
+
// Only authentication state goes; providers, keys, and routing stay.
|
|
597
|
+
if (deps.drainToken) {
|
|
598
|
+
app.post(
|
|
599
|
+
"/admin/reset-console-account",
|
|
600
|
+
createConsoleAccountResetHandler({
|
|
601
|
+
token: deps.drainToken,
|
|
602
|
+
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
603
|
+
resetConsoleAccount: () => resetConsoleAuthState(deps.db),
|
|
604
|
+
}),
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Manual task trigger. Same gate as drain: loopback plus the drain token,
|
|
609
|
+
// so a leaked token still cannot reach it from outside the host. 202 once
|
|
610
|
+
// the run has started; 409 when that task is already mid-run, because the
|
|
611
|
+
// scheduler refuses to overlap a task with itself and a second trigger
|
|
612
|
+
// would otherwise look like it ran.
|
|
613
|
+
if (deps.drainToken && deps.scheduledTasks) {
|
|
614
|
+
const tasks = deps.scheduledTasks;
|
|
615
|
+
const token = deps.drainToken;
|
|
616
|
+
app.post("/admin/tasks/:name/run", async ({ request, params }) => {
|
|
617
|
+
// `policy_rejected`, not `invalid_request`: the request may be perfectly
|
|
618
|
+
// well-formed. What refused it is where it came from — the client asked
|
|
619
|
+
// for something only the local host may ask. `invalid_request` told an
|
|
620
|
+
// operator to fix the body, when the body was never the problem.
|
|
621
|
+
if (!isLoopbackPeer(peerAddresses.get(request) ?? null))
|
|
622
|
+
throw new GatewayError("policy_rejected", 403, "task trigger is only available from loopback");
|
|
623
|
+
if (!tokenMatches(request.headers.get("x-drain-token"), token))
|
|
624
|
+
throw new GatewayError("authentication_failed", 401, "invalid drain token");
|
|
625
|
+
const name = params.name;
|
|
626
|
+
if (!tasks.names().includes(name))
|
|
627
|
+
throw new GatewayError("invalid_request", 404, `no such task: ${name}`);
|
|
628
|
+
if (tasks.isRunning(name))
|
|
629
|
+
throw new GatewayError("invalid_request", 409, `${name} is already running`);
|
|
630
|
+
void tasks.runNow(name);
|
|
631
|
+
return new Response(JSON.stringify({ task: name, status: "started" }), {
|
|
632
|
+
status: 202,
|
|
633
|
+
headers: { "content-type": "application/json" },
|
|
634
|
+
});
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// `peerAddresses` is populated by the root `beforeHandle` above from
|
|
639
|
+
// `server.requestIP`. The console login route needs it to key its lockout
|
|
640
|
+
// bucket, and the trusted-proxy boundary decides whether an
|
|
641
|
+
// `X-Forwarded-For` hop may override it — the same pair the transport
|
|
642
|
+
// pipeline receives, so console and proxy agree on client identity.
|
|
643
|
+
if (deps.consoleApi) {
|
|
644
|
+
app.use(
|
|
645
|
+
createConsoleRouter({
|
|
646
|
+
...deps.consoleApi,
|
|
647
|
+
stateStore: requestStateStore,
|
|
648
|
+
resolvePeerAddress: (request) => peerAddresses.get(request) ?? null,
|
|
649
|
+
trustedProxyBoundary: deps.trustedProxyBoundary,
|
|
650
|
+
}),
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Public share API routes are registered before the SPA catch-all, and
|
|
655
|
+
// enrollment uses only the peer address captured by the trusted boundary.
|
|
656
|
+
app.use(
|
|
657
|
+
createShareRouter({
|
|
658
|
+
db: deps.db,
|
|
659
|
+
shareStore: new DrizzleShareLinkStore(deps.db),
|
|
660
|
+
stats: createShareStatsPort({ redis: deps.consoleApi?.redis }),
|
|
661
|
+
resolveClientIp: (request) => {
|
|
662
|
+
const peer = peerAddresses.get(request);
|
|
663
|
+
return peer
|
|
664
|
+
? resolveClientIdentity(request, deps.trustedProxyBoundary, peer)
|
|
665
|
+
: null;
|
|
666
|
+
},
|
|
667
|
+
}),
|
|
668
|
+
);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
app.all("/", serveDashboard);
|
|
672
|
+
app.all("/share", serveDashboard);
|
|
673
|
+
app.all("/share/*", serveDashboard);
|
|
674
|
+
app.all("/console", serveDashboard);
|
|
675
|
+
app.all("/console/*", serveDashboard);
|
|
676
|
+
// Root-level build output (hashed bundles, chapter art, provider icons,
|
|
677
|
+
// favicons). Registered last so every real API route above wins.
|
|
678
|
+
app.all("/*", serveRootFallback);
|
|
679
|
+
return app;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Route-only convenience wrapper. It earns its place (unlike a bare forwarder)
|
|
684
|
+
* by defaulting `options` and injecting the discriminant, so callers never
|
|
685
|
+
* write `mode` for the shell path.
|
|
686
|
+
*/
|
|
687
|
+
export function createGatewayShell(options: Omit<GatewayShellDeps, "mode"> = {}) {
|
|
688
|
+
return createGatewayApp({ mode: "route-only", ...options });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Elysia route-tree type for Eden Treaty dashboard consumption.
|
|
693
|
+
*
|
|
694
|
+
|
|
695
|
+
* inferred return type.
|
|
696
|
+
*/
|
|
697
|
+
export type App = ReturnType<typeof createGatewayApp>;
|