cartethyia 2.8.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -3
- package/dist/dashboard/assets/{BannedPage-Cc0vKpMG.js → BannedPage-Dq530awD.js} +1 -1
- package/dist/dashboard/assets/BannedPage-Dq530awD.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-Bqj2aa1T.js → CliToolDetailPage-DZzaS-7S.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DZzaS-7S.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-CXsaOAO2.js → CliToolsPage-DaZZ3cqP.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DaZZ3cqP.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BeLpJ4ma.js → CombosPage-BlC_xdkX.js} +1 -1
- package/dist/dashboard/assets/CombosPage-BlC_xdkX.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-DnV0uZ3m.js → ConsoleLogPage-D-rqeVtG.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-D-rqeVtG.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-CBxAjo63.js → CustomizationPage-7xT5yg2E.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-7xT5yg2E.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-B5yEE0k6.js → HealthEventsModal-Clq8E9cW.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Clq8E9cW.js.gz +0 -0
- package/dist/dashboard/assets/{InjectSystemPromptPanel-BWFLECmz.js → InjectSystemPromptPanel-6Teoq9Se.js} +1 -1
- package/dist/dashboard/assets/InjectSystemPromptPanel-6Teoq9Se.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-CgoPc5B9.js → LoginPage-CZuI0MAR.js} +1 -1
- package/dist/dashboard/assets/LoginPage-CZuI0MAR.js.gz +0 -0
- package/dist/dashboard/assets/{ModelPicker-D9bFQern.js → ModelPicker-C1hNTlkr.js} +1 -1
- package/dist/dashboard/assets/ModelPicker-C1hNTlkr.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-B_p4tL8k.js → OverviewPage-0yfhSI1C.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-0yfhSI1C.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-xqTxdQ4c.js +8 -0
- package/dist/dashboard/assets/ProviderDetailPage-xqTxdQ4c.js.gz +0 -0
- package/dist/dashboard/assets/{ProviderIcon-D_0PiZtg.js → ProviderIcon-Ddhm1InL.js} +1 -1
- package/dist/dashboard/assets/ProviderIcon-Ddhm1InL.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-CLI2bJTK.js → ProvidersPage-C3fYuu6i.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-C3fYuu6i.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-CHtJtZ3u.js +9 -0
- package/dist/dashboard/assets/ProxyPage-CHtJtZ3u.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-DA-a1iSN.js → QuotaPage-D1RV_2BX.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-D1RV_2BX.js.gz +0 -0
- package/dist/dashboard/assets/{SettingsPage-CctTga2C.js → SettingsPage-BzZAvYSX.js} +1 -1
- package/dist/dashboard/assets/SettingsPage-BzZAvYSX.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-febLU4r1.js → SetupPage--s-EfEIL.js} +1 -1
- package/dist/dashboard/assets/SetupPage--s-EfEIL.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-M6fvR5Wz.js → StudioPage-BjXFGSK4.js} +1 -1
- package/dist/dashboard/assets/StudioPage-BjXFGSK4.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-nyeuRqz9.js → UsagePage-CP8F8BN7.js} +1 -1
- package/dist/dashboard/assets/UsagePage-CP8F8BN7.js.gz +0 -0
- package/dist/dashboard/assets/{api-keys-BFOlvrJC.js → api-keys-2hc12Uz2.js} +1 -1
- package/dist/dashboard/assets/api-keys-2hc12Uz2.js.gz +0 -0
- package/dist/dashboard/assets/app-DGXbNfAR.css +2 -0
- package/dist/dashboard/assets/app-DGXbNfAR.css.gz +0 -0
- package/dist/dashboard/assets/{assert-CqTLw5e7.js → assert-BMcj1xZE.js} +1 -1
- package/dist/dashboard/assets/assert-BMcj1xZE.js.gz +0 -0
- package/dist/dashboard/assets/{badge-C83EqLB0.js → badge-DkRUuVfz.js} +1 -1
- package/dist/dashboard/assets/badge-DkRUuVfz.js.gz +0 -0
- package/dist/dashboard/assets/{banner-D1xn7pIe.js → banner-CDjW841Q.js} +1 -1
- package/dist/dashboard/assets/banner-CDjW841Q.js.gz +0 -0
- package/dist/dashboard/assets/{card-DU3Lq5Lm.js → card-Bto5UlL9.js} +1 -1
- package/dist/dashboard/assets/card-Bto5UlL9.js.gz +0 -0
- package/dist/dashboard/assets/{cli-tools-Dw1U_drK.js → cli-tools-DaH6g9hL.js} +1 -1
- package/dist/dashboard/assets/cli-tools-DaH6g9hL.js.gz +0 -0
- package/dist/dashboard/assets/{clipboard-button-CEL5Sg_J.js → clipboard-button-BYKBCzjk.js} +1 -1
- package/dist/dashboard/assets/clipboard-button-BYKBCzjk.js.gz +0 -0
- package/dist/dashboard/assets/{dialog-BjFR5UHy.js → dialog-DL0Zftwv.js} +1 -1
- package/dist/dashboard/assets/dialog-DL0Zftwv.js.gz +0 -0
- package/dist/dashboard/assets/{drawer-HvBW0AjI.js → drawer-Dpx_UXaS.js} +1 -1
- package/dist/dashboard/assets/drawer-Dpx_UXaS.js.gz +0 -0
- package/dist/dashboard/assets/{entry-CECVELyy.js → entry-BV7-1NoM.js} +1 -1
- package/dist/dashboard/assets/entry-BV7-1NoM.js.gz +0 -0
- package/dist/dashboard/assets/{entry-DYYofQ7W.js → entry-CGvdNU6r.js} +1 -1
- package/dist/dashboard/assets/entry-CGvdNU6r.js.gz +0 -0
- package/dist/dashboard/assets/{entry-BSu3gFlt.js → entry-DScKYvZj.js} +3 -3
- package/dist/dashboard/assets/entry-DScKYvZj.js.gz +0 -0
- package/dist/dashboard/assets/{formatters-DLW4i8zK.js → formatters-BS3DZlZj.js} +1 -1
- package/dist/dashboard/assets/formatters-BS3DZlZj.js.gz +0 -0
- package/dist/dashboard/assets/{index-1-fCqe64.js → index-SQnvvTWX.js} +2 -2
- package/dist/dashboard/assets/index-SQnvvTWX.js.gz +0 -0
- package/dist/dashboard/assets/{inline-DGvU2oLK.js → inline-Dl-cuLK0.js} +1 -1
- package/dist/dashboard/assets/inline-Dl-cuLK0.js.gz +0 -0
- package/dist/dashboard/assets/{input-iNnO1X2K.js → input-Bfx0EzRc.js} +1 -1
- package/dist/dashboard/assets/input-Bfx0EzRc.js.gz +0 -0
- package/dist/dashboard/assets/{layout-0q8UGypb.js → layout-BxhbQ_JS.js} +1 -1
- package/dist/dashboard/assets/layout-BxhbQ_JS.js.gz +0 -0
- package/dist/dashboard/assets/{list-row-C0w2bQjJ.js → list-row-D49Y4ptn.js} +1 -1
- package/dist/dashboard/assets/list-row-D49Y4ptn.js.gz +0 -0
- package/dist/dashboard/assets/{live-M5aJ_xCa.js → live-CbVTdX9V.js} +1 -1
- package/dist/dashboard/assets/live-CbVTdX9V.js.gz +0 -0
- package/dist/dashboard/assets/{network-BKeQGT3K.js → network-JGvDveof.js} +1 -1
- package/dist/dashboard/assets/network-JGvDveof.js.gz +0 -0
- package/dist/dashboard/assets/{numbers-CivAk3da.js → numbers-Dw6kiew-.js} +1 -1
- package/dist/dashboard/assets/numbers-Dw6kiew-.js.gz +0 -0
- package/dist/dashboard/assets/{pagination-CpzvI5ep.js → pagination-iUoI8GeE.js} +1 -1
- package/dist/dashboard/assets/pagination-iUoI8GeE.js.gz +0 -0
- package/dist/dashboard/assets/providers/sensenova-intl.png +0 -0
- package/dist/dashboard/assets/providers/sensenova.png +0 -0
- package/dist/dashboard/assets/{routing-BFQX2VBc.js → routing-8jZr-3WE.js} +1 -1
- package/dist/dashboard/assets/routing-8jZr-3WE.js.gz +0 -0
- package/dist/dashboard/assets/{select-CXbck0jG.js → select-oO1FxMVb.js} +1 -1
- package/dist/dashboard/assets/select-oO1FxMVb.js.gz +0 -0
- package/dist/dashboard/assets/{settings-C_XvMHh9.js → settings-CLr3kdIx.js} +1 -1
- package/dist/dashboard/assets/settings-CLr3kdIx.js.gz +0 -0
- package/dist/dashboard/assets/{stack-DggRiCxE.js → stack-Y3pDteZr.js} +1 -1
- package/dist/dashboard/assets/stack-Y3pDteZr.js.gz +0 -0
- package/dist/dashboard/assets/{state-ClFzfbYp.js → state-CArX_Iak.js} +1 -1
- package/dist/dashboard/assets/state-CArX_Iak.js.gz +0 -0
- package/dist/dashboard/assets/{switch-CTIFxKqS.js → switch-DwlwmDO-.js} +1 -1
- package/dist/dashboard/assets/switch-DwlwmDO-.js.gz +0 -0
- package/dist/dashboard/assets/{useMutation-BClG3yWj.js → useMutation-DJ9GYEgf.js} +1 -1
- package/dist/dashboard/assets/useMutation-DJ9GYEgf.js.gz +0 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +51 -51
- package/migrations/full-postgres/0047_add_provider_device_flow_proxy.sql +17 -0
- package/migrations/lite-sqlite3/0047_add_provider_device_flow_proxy.sql +19 -0
- package/package.json +1 -1
- package/src/boot/app.ts +1 -1
- package/src/boot/dependencies.ts +4 -6
- package/src/config/providers.ts +6 -5
- package/src/config/spec.ts +8 -8
- package/src/console/backup/nine-router.ts +71 -10
- package/src/console/backup/service.ts +8 -1
- package/src/console/console-router.ts +1 -1
- package/src/console/domain-registration.ts +7 -2
- package/src/console/inferhub/client.ts +1 -1
- package/src/console/observability/live.ts +2 -2
- package/src/console/providers/catalog/contracts.ts +9 -3
- package/src/console/providers/catalog/model-operations.ts +2 -3
- package/src/console/providers/catalog/provider-operations.ts +1 -1
- package/src/console/providers/catalog/routes.ts +2 -2
- package/src/console/providers/catalog/store.ts +1914 -1907
- package/src/console/providers/detail/routes.ts +1 -0
- package/src/console/providers/detail/store.ts +4 -0
- package/src/console/providers/oauth/callback-listener.ts +30 -11
- package/src/console/providers/oauth/device-egress.ts +123 -0
- package/src/console/providers/oauth/routes.ts +123 -16
- package/src/console/quota/account-quota-tenant.ts +5 -5
- package/src/console/quota/account-test-egress.ts +2 -2
- package/src/console/quota/daily-growth-job.ts +1 -1
- package/src/console/routing/pools/contracts.ts +1 -1
- package/src/console/routing/pools/operations.ts +1 -1
- package/src/console/routing/pools/probe-verdict.ts +2 -2
- package/src/console/routing/pools/relay-deploy.ts +1 -1
- package/src/console/routing/pools/store.ts +4 -4
- package/src/network/client.ts +67 -0
- package/src/network/egress/bidi-session.ts +322 -0
- package/src/network/egress/dial.ts +201 -0
- package/src/network/egress/factory.ts +191 -0
- package/src/network/{outbound-fetch.ts → egress/fetch.ts} +195 -29
- package/src/network/egress/tls-profile.ts +123 -0
- package/src/network/egress/ws.ts +175 -0
- package/src/network/{types.ts → pool/agent-config.ts} +1 -1
- package/src/network/pool/agent.ts +2 -2
- package/src/network/{pool-cooldown-store.ts → pool/cooldown-store.ts} +7 -7
- package/src/network/{pool-health-machine.ts → pool/health.ts} +205 -6
- package/src/network/pool/resolver.ts +10 -849
- package/src/network/pool/selector.ts +2 -2
- package/src/network/ssrf.ts +1 -1
- package/src/network/{retry.ts → util/retry.ts} +1 -1
- package/src/observability/telemetry-buffer.ts +1 -1
- package/src/persistence/schema.ts +9 -0
- package/src/persistence/ttl-cache.ts +1 -1
- package/src/protocol/codecs.ts +58 -1
- package/src/protocol/request/messages.ts +31 -18
- package/src/protocol/response/messages.ts +34 -7
- package/src/protocol/translation/quirks.ts +15 -0
- package/src/protocol/upstream/send-claude-messages.ts +2 -2
- package/src/providers/adapters/compatible-adapter.ts +5 -0
- package/src/providers/authentication/oauth-client.ts +40 -6
- package/src/providers/authentication/oauth-device-flow.ts +7 -3
- package/src/providers/authentication/oauth-flow-store.ts +82 -1
- package/src/providers/authentication/oauth-refresh-service.ts +766 -739
- package/src/providers/default-registry.ts +76 -0
- package/src/providers/discovery/discovery-types.ts +7 -0
- package/src/providers/discovery/probe-phases.ts +6 -2
- package/src/providers/discovery/probing-service.ts +7 -0
- package/src/providers/integrations/antigravity/antigravity-protocol.ts +11 -0
- package/src/providers/integrations/buddy/account.ts +969 -0
- package/src/providers/integrations/buddy/capabilities.ts +10 -10
- package/src/providers/integrations/buddy/{buddy-catalog-shared.ts → catalog.ts} +198 -136
- package/src/providers/integrations/buddy/{buddy-chat-shared.ts → chat.ts} +5 -5
- package/src/providers/integrations/buddy/codebuddy/index.ts +429 -0
- package/src/providers/integrations/buddy/codebuddy/shared.ts +4 -4
- package/src/providers/integrations/buddy/{buddy-identity.ts → headers.ts} +138 -140
- package/src/providers/integrations/buddy/lifecycle.ts +1018 -0
- package/src/providers/integrations/buddy/{buddy-media.ts → media.ts} +1 -1
- package/src/providers/integrations/cline/cline-oauth.ts +10 -5
- package/src/providers/integrations/cline/cline.ts +3 -4
- package/src/providers/integrations/clixad/clixad-oauth.ts +98 -0
- package/src/providers/integrations/clixad/clixad-quota.ts +132 -0
- package/src/providers/integrations/clixad/provider.ts +145 -0
- package/src/providers/integrations/codex/codex-oauth.ts +8 -5
- package/src/providers/integrations/cursor/cursor-auth.ts +1 -1
- package/src/providers/integrations/cursor/cursor-hardening.ts +279 -0
- package/src/providers/integrations/cursor/cursor-http2.ts +605 -6
- package/src/providers/integrations/cursor/cursor-shared.ts +1 -1
- package/src/providers/integrations/cursor/cursor.ts +485 -70
- package/src/providers/integrations/deepai/deepai-catalog.ts +149 -0
- package/src/providers/integrations/deepai/deepai-common.ts +145 -0
- package/src/providers/integrations/deepai/deepai-oauth.ts +129 -0
- package/src/providers/integrations/deepai/deepai-quota.ts +97 -0
- package/src/providers/integrations/deepai/provider.ts +42 -0
- package/src/providers/integrations/devin/devin-auth.ts +7 -0
- package/src/providers/integrations/factory/factory-oauth.ts +16 -7
- package/src/providers/integrations/freebuff/freebuff.ts +2 -2
- package/src/providers/integrations/freepi/freepi-catalog.ts +5 -0
- package/src/providers/integrations/freepi/freepi-discovery.ts +2 -2
- package/src/providers/integrations/freepi/freepi-headers.ts +2 -2
- package/src/providers/integrations/freepi/freepi-protocol.ts +11 -2
- package/src/providers/integrations/freepi/freepi-realm.ts +0 -3
- package/src/providers/integrations/freepi/freepi-session.ts +158 -0
- package/src/providers/integrations/freepi/freepi.ts +80 -54
- package/src/providers/integrations/github/github-discovery.ts +30 -13
- package/src/providers/integrations/github/github-oauth.ts +217 -44
- package/src/providers/integrations/github/github-quota.ts +22 -5
- package/src/providers/integrations/github/github.ts +40 -32
- package/src/providers/integrations/grok/grok-oauth.ts +4 -4
- package/src/providers/integrations/hermes/hermes-oauth.ts +3 -3
- package/src/providers/integrations/hermes/hermes.ts +34 -5
- package/src/providers/integrations/kilo/kilo-oauth.ts +4 -4
- package/src/providers/integrations/kilo/kilo.ts +1 -1
- package/src/providers/integrations/kimi/kimi-oauth.ts +3 -3
- package/src/providers/integrations/kiro/kiro-oauth.ts +2 -2
- package/src/providers/integrations/muse/muse-oauth.ts +5 -5
- package/src/providers/integrations/opencode/fingerprint.ts +1 -1
- package/src/providers/integrations/openrouter/provider.ts +1 -1
- package/src/providers/integrations/perch/perch-protocol.ts +0 -3
- package/src/providers/integrations/perch/perch.ts +3 -3
- package/src/providers/integrations/qoder/claim-qoder-credits.ts +1 -1
- package/src/providers/integrations/qoder/qoder-adapter.ts +19 -113
- package/src/providers/integrations/qoder/qoder-auth.ts +8 -1
- package/src/providers/integrations/qoder/qoder-catalog.ts +1 -1
- package/src/providers/integrations/qoder/qoder-chat.ts +25 -8
- package/src/providers/integrations/qoder/qoder-checkin.ts +1 -1
- package/src/providers/integrations/qoder/qoder-cosy.ts +37 -9
- package/src/providers/integrations/qoder/qoder-oauth.ts +9 -2
- package/src/providers/integrations/qoder/qoder-realm.ts +13 -36
- package/src/providers/integrations/qoder/qoder-sse.ts +447 -68
- package/src/providers/integrations/qoder/qoder-types.ts +9 -0
- package/src/providers/integrations/qoder/qoder.ts +3 -25
- package/src/providers/integrations/sensenova/provider.ts +144 -0
- package/src/providers/integrations/trae/trae-quota.ts +15 -1
- package/src/providers/integrations/vercel/provider.ts +117 -0
- package/src/providers/integrations/vercel/vercel-quota.ts +96 -0
- package/src/providers/integrations/xai/xai-oauth.ts +5 -5
- package/src/providers/integrations/zcode-start-plan/gifts.ts +1 -1
- package/src/providers/integrations/zcode-start-plan/oauth.ts +3 -3
- package/src/providers/integrations/zro/zro.ts +2 -1
- package/src/providers/operations/account-refresh-status.ts +14 -0
- package/src/providers/operations/client-versions.ts +95 -30
- package/src/providers/operations/oauth-refresh-lead.ts +46 -22
- package/src/providers/operations/provider-caches.ts +1 -1
- package/src/providers/operations/provider-credential-service.ts +22 -25
- package/src/providers/operations/session-resolution.ts +17 -1
- package/src/providers/provider-contracts.ts +2 -2
- package/src/providers/provider-metadata.ts +30 -0
- package/src/providers/quota/quota-contracts.ts +2 -0
- package/src/providers/quota/refresh.ts +25 -3
- package/src/security/ip-boundary.ts +1 -1
- package/src/transport/dispatch/attempt-finalize.ts +1 -1
- package/src/transport/dispatch/attempt-loop.ts +3 -3
- package/src/transport/dispatch/handler-deps.ts +1 -1
- package/src/transport/dispatch/leases.ts +1 -1
- package/src/transport/dispatch/retry-policy.ts +1 -1
- package/src/transport/dispatch/streaming/finalize.ts +2 -2
- package/src/transport/dispatch/streaming/stream.ts +1 -1
- package/src/transport/dispatch/upstream.ts +1 -1
- package/src/transport/shutdown-notice.ts +1 -1
- package/src/workers/buddy-growth-tasks.ts +8 -8
- package/src/workers/daily-checkin.ts +4 -4
- package/src/workers/quota-refresh-sweep.ts +1 -1
- package/src/workers/zcode-gift-claim.ts +1 -1
- package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
- package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
- package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +0 -8
- package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
- package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js +0 -9
- package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
- package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
- package/dist/dashboard/assets/app-Dx2QgsNj.css +0 -2
- package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
- package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
- package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
- package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
- package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
- package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
- package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
- package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
- package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
- package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
- package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
- package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
- package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
- package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
- package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
- package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
- package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
- package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
- package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
- package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
- package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
- package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
- package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
- package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
- package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
- package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
- package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
- package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
- package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
- package/src/console/providers/catalog/discover-constants.ts +0 -6
- package/src/network/pool-health.ts +0 -198
- package/src/providers/integrations/buddy/buddy-checkin.ts +0 -521
- package/src/providers/integrations/buddy/buddy-cli-headers.ts +0 -76
- package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -130
- package/src/providers/integrations/buddy/buddy-discovery-shared.ts +0 -234
- package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -496
- package/src/providers/integrations/buddy/buddy-oauth-shared.ts +0 -341
- package/src/providers/integrations/buddy/buddy-quota-shared.ts +0 -417
- package/src/providers/integrations/buddy/buddy-server-tools.ts +0 -240
- package/src/providers/integrations/buddy/buddy-usage-shared.ts +0 -221
- package/src/providers/integrations/buddy/buddy-usage-store.ts +0 -181
- package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +0 -37
- package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +0 -188
- package/src/providers/integrations/buddy/codebuddy/oauth.ts +0 -47
- package/src/providers/integrations/buddy/codebuddy/provider.ts +0 -179
- package/src/providers/integrations/buddy/codebuddy/quota.ts +0 -154
- package/src/providers/integrations/qoder/chat-proto.ts +0 -328
- package/src/providers/integrations/qoder/qoder-grpc.ts +0 -870
- package/src/providers/integrations/qoder/qoder-ide.ts +0 -481
- /package/dist/dashboard/assets/{app-DIXLjojg.js → app-C0gTXEoN.js} +0 -0
- /package/dist/dashboard/assets/{app-DIXLjojg.js.gz → app-C0gTXEoN.js.gz} +0 -0
- /package/src/network/{http2-bidi.ts → bidi.ts} +0 -0
- /package/src/network/pool/{byte-accounting.ts → accounting.ts} +0 -0
- /package/src/network/pool/{pool-scripts.ts → scripts.ts} +0 -0
- /package/src/network/pool/{pool-state.ts → state.ts} +0 -0
- /package/src/network/{backoff.ts → util/backoff.ts} +0 -0
- /package/src/network/{deduplication.ts → util/dedupe.ts} +0 -0
- /package/src/network/{ip-utils.ts → util/ip.ts} +0 -0
- /package/src/network/{rate-limit-verdict.ts → verdict.ts} +0 -0
|
@@ -1,1907 +1,1914 @@
|
|
|
1
|
-
// Drizzle-backed provider, account, model, routing, and API-key repositories.
|
|
2
|
-
import { and, asc, eq, gte, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
|
|
3
|
-
import { liveModelCooldownExistsSql } from "../../../persistence/dialect";
|
|
4
|
-
import { ConsoleDomainError } from "../../shared/errors";
|
|
5
|
-
import { parseCustomProviderId, isBundledProviderId } from "../../../providers/provider-contracts";
|
|
6
|
-
import { providerRateLimitIsIpScoped } from "../../../providers/provider-metadata";
|
|
7
|
-
import type { ModelDefinition } from "../../../providers/provider-model-definition";
|
|
8
|
-
import type { ProviderRegistry } from "../../../providers/provider-registry";
|
|
9
|
-
import { BUNDLED_PROVIDER_MODULES } from "../../../providers/default-registry";
|
|
10
|
-
import { globalOrOwnedBy, ownedByOnly } from "../../../persistence/tenant-scope";
|
|
11
|
-
import { nextSortIndex, reorderSortIndex } from "../../../persistence/sort-order";
|
|
12
|
-
import type { CartethyiaDatabase } from "../../../persistence/db-handle";
|
|
13
|
-
import { buddyTaskLedger, models, networkPools, providerAccounts, providerOauthStates, providerPoolAccounts, providers, telemetryEvents, telemetryUsageTotals, tenantDisabledModels } from "../../../persistence/schema";
|
|
14
|
-
import { IS_GATEWAY_ERROR_SQL } from "../../../observability/event-query";
|
|
15
|
-
import type { WireFamily } from "../../../protocol/canonical-model";
|
|
16
|
-
import { listAccountHealthEvents, recoverAccount, deleteAccountHealthEvents, type AccountHealthEventRecord } from "../../../providers/operations/account-health-service";
|
|
17
|
-
import { invalidateCredentialCache, invalidateCredentialCaches } from "../../../providers/operations/provider-credential-service";
|
|
18
|
-
import { invalidateQuotaCaches } from "../../../providers/quota/cache";
|
|
19
|
-
import { encryptCredential, hashSecret } from "../../../security/crypto";
|
|
20
|
-
import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
|
|
21
|
-
import type { RedisClient } from "../../../persistence/redis";
|
|
22
|
-
import { bumpCatalogRevision, type BundledProviderCatalog } from "../../../providers/operations/provider-catalog-service";
|
|
23
|
-
import type { AccountInflightReading, BuddyGrowthTaskLedgerEntry, ByokConnectionTestRequest, ByokConnectionTestResult, CreateProviderAccountBatchItemResult, CreateProviderAccountBatchResult, CreateProviderAccountRequest, CredentialMode, DiscoverModelCandidate, ModelCatalogEntry, ProbeAllAccountsResult, ProbeAllModelsResult, ProbeModelRequest, ProbeModelResult, ProviderAccountBulkActionResult, ProviderAccountResponse, ProviderAccountTokenUsage, ProviderCatalogStore, ProviderRecord, SetModelEnabledRequest, UpdateModelMetadataRequest, UpdateProviderAccountRequest, ModelMetadataOverride } from "./contracts";
|
|
24
|
-
import { isServiceKind, validateCompatibilityProfile } from "./contracts";
|
|
25
|
-
import { ProviderProbingService, type ProbeOutboundResolver } from "../../../providers/discovery/probing-service";
|
|
26
|
-
import {
|
|
27
|
-
applyModelMetadataOverride,
|
|
28
|
-
resolveManualModelMetadata,
|
|
29
|
-
} from "../../../providers/provider-model-definition";
|
|
30
|
-
import { isUniqueViolation } from "../../../persistence/boot";
|
|
31
|
-
import { DEFAULT_ENDPOINT_BY_WIRE_FAMILY, endpointPathForProviderModel, mapProviderRow } from "./catalog-projections";
|
|
32
|
-
import { pushStructuredConsoleLog } from "../../../observability/log-ring";
|
|
33
|
-
|
|
34
|
-
const emptyUsage: ProviderAccountTokenUsage = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* The owning pool's provider cooldown markers, filtered to the ones still in
|
|
38
|
-
* force and shaped like `NetworkPoolResponse.providerCooldowns`. Expired
|
|
39
|
-
* markers are skipped — a spent window is not a reason to flag the row.
|
|
40
|
-
*/
|
|
41
|
-
function activePoolProviderCooldowns(
|
|
42
|
-
markers: Record<string, { until: number; reason: string; blocked?: boolean }>,
|
|
43
|
-
): { providerId: string; until: string; reason: string; blocked: boolean }[] {
|
|
44
|
-
const now = Date.now();
|
|
45
|
-
const active: { providerId: string; until: string; reason: string; blocked: boolean }[] = [];
|
|
46
|
-
for (const [providerId, marker] of Object.entries(markers)) {
|
|
47
|
-
if (marker.until <= now) continue;
|
|
48
|
-
active.push({
|
|
49
|
-
providerId,
|
|
50
|
-
until: new Date(marker.until).toISOString(),
|
|
51
|
-
reason: marker.reason,
|
|
52
|
-
blocked: marker.blocked === true,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
return active;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** Real Drizzle-backed provider and model catalog repository. */
|
|
59
|
-
|
|
60
|
-
import {
|
|
61
|
-
asStoredText,
|
|
62
|
-
liveModelCooldowns,
|
|
63
|
-
mapModelRow,
|
|
64
|
-
nextGeneratedAccountLabel,
|
|
65
|
-
parseJwtCredential,
|
|
66
|
-
parsePastedOAuthCredential,
|
|
67
|
-
withImageModality,
|
|
68
|
-
} from "./store-helpers";
|
|
69
|
-
|
|
70
|
-
export {
|
|
71
|
-
accountLabelKindSegment,
|
|
72
|
-
mapModelRow,
|
|
73
|
-
nextGeneratedAccountLabel,
|
|
74
|
-
withImageModality,
|
|
75
|
-
} from "./store-helpers";
|
|
76
|
-
|
|
77
|
-
export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
78
|
-
private readonly probing: ProviderProbingService;
|
|
79
|
-
private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
|
|
80
|
-
private readonly providerRegistry: ProviderRegistry;
|
|
81
|
-
private readonly redis: RedisClient | undefined;
|
|
82
|
-
|
|
83
|
-
private readonly readAccountInflight:
|
|
84
|
-
| ((
|
|
85
|
-
providerId: string,
|
|
86
|
-
tenantId: string,
|
|
87
|
-
) => Promise<readonly AccountInflightReading[]>)
|
|
88
|
-
| undefined;
|
|
89
|
-
|
|
90
|
-
constructor(
|
|
91
|
-
private readonly db: CartethyiaDatabase,
|
|
92
|
-
options: {
|
|
93
|
-
readonly telemetryBuffer: TelemetryBatchBuffer;
|
|
94
|
-
readonly bundledModelCatalog: BundledProviderCatalog;
|
|
95
|
-
readonly providerRegistry: ProviderRegistry;
|
|
96
|
-
readonly outboundFetchFor: ProbeOutboundResolver;
|
|
97
|
-
readonly snapshotInvalidator: { invalidate(): unknown };
|
|
98
|
-
readonly readAccountInflight?:
|
|
99
|
-
| ((
|
|
100
|
-
providerId: string,
|
|
101
|
-
tenantId: string,
|
|
102
|
-
) => Promise<readonly AccountInflightReading[]>)
|
|
103
|
-
| undefined;
|
|
104
|
-
/** Shared Redis handle for quota-cache invalidation on bulk delete. */
|
|
105
|
-
readonly redis?: RedisClient | undefined;
|
|
106
|
-
},
|
|
107
|
-
) {
|
|
108
|
-
this.bundledModelCatalog = options.bundledModelCatalog.modelsByProvider;
|
|
109
|
-
this.providerRegistry = options.providerRegistry;
|
|
110
|
-
this.readAccountInflight = options.readAccountInflight;
|
|
111
|
-
this.redis = options.redis;
|
|
112
|
-
this.probing = new ProviderProbingService({
|
|
113
|
-
db,
|
|
114
|
-
telemetryBuffer: options.telemetryBuffer,
|
|
115
|
-
defaultEndpoints: DEFAULT_ENDPOINT_BY_WIRE_FAMILY,
|
|
116
|
-
bundledModelCatalog: this.bundledModelCatalog,
|
|
117
|
-
outboundFetchFor: options.outboundFetchFor,
|
|
118
|
-
snapshotInvalidator: options.snapshotInvalidator,
|
|
119
|
-
providerRegistry: options.providerRegistry,
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
async list(tenantId: string): Promise<readonly ProviderRecord[]> {
|
|
123
|
-
const bundledIds = BUNDLED_PROVIDER_MODULES.map((provider) => provider.id);
|
|
124
|
-
const rows = await this.db
|
|
125
|
-
.select()
|
|
126
|
-
.from(providers)
|
|
127
|
-
.where(
|
|
128
|
-
and(
|
|
129
|
-
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
130
|
-
or(inArray(providers.id, bundledIds), eq(providers.tenantId, tenantId)),
|
|
131
|
-
),
|
|
132
|
-
)
|
|
133
|
-
.orderBy(asc(providers.id));
|
|
134
|
-
return rows.map((row) => mapProviderRow(row, this.providerRegistry));
|
|
135
|
-
}
|
|
136
|
-
async get(tenantId: string, providerId: string): Promise<ProviderRecord | undefined> {
|
|
137
|
-
const rows = await this.db
|
|
138
|
-
.select()
|
|
139
|
-
.from(providers)
|
|
140
|
-
.where(
|
|
141
|
-
and(
|
|
142
|
-
eq(providers.id, providerId),
|
|
143
|
-
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
144
|
-
),
|
|
145
|
-
)
|
|
146
|
-
.limit(1);
|
|
147
|
-
const row = rows[0];
|
|
148
|
-
return row ? mapProviderRow(row, this.providerRegistry) : undefined;
|
|
149
|
-
}
|
|
150
|
-
async create(record: ProviderRecord): Promise<void> {
|
|
151
|
-
if (isBundledProviderId(record.providerId))
|
|
152
|
-
throw new ConsoleDomainError(
|
|
153
|
-
"provider_is_builtin",
|
|
154
|
-
403,
|
|
155
|
-
"Built-in provider cannot be created",
|
|
156
|
-
);
|
|
157
|
-
parseCustomProviderId(record.providerId);
|
|
158
|
-
if (record.compatibilityProfile) validateCompatibilityProfile(record.compatibilityProfile);
|
|
159
|
-
await this.db.insert(providers).values({
|
|
160
|
-
id: record.providerId,
|
|
161
|
-
tenantId: record.tenantId ?? null,
|
|
162
|
-
enabled: record.enabled,
|
|
163
|
-
...(record.wireFamilyDefault ? { wireFamilyDefault: record.wireFamilyDefault } : {}),
|
|
164
|
-
capabilityProfile: record.capabilityProfile ?? null,
|
|
165
|
-
baseUrl: record.baseUrl ?? null,
|
|
166
|
-
compatibilityProfile: record.compatibilityProfile ?? null,
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
async update(
|
|
170
|
-
tenantId: string,
|
|
171
|
-
providerId: string,
|
|
172
|
-
patch: Partial<ProviderRecord>,
|
|
173
|
-
): Promise<ProviderRecord | undefined> {
|
|
174
|
-
if (isBundledProviderId(providerId))
|
|
175
|
-
throw new ConsoleDomainError(
|
|
176
|
-
"provider_is_builtin",
|
|
177
|
-
403,
|
|
178
|
-
"Built-in provider cannot be updated",
|
|
179
|
-
);
|
|
180
|
-
if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
|
|
181
|
-
const set: Partial<typeof providers.$inferInsert> = {};
|
|
182
|
-
if (patch.enabled !== undefined) set.enabled = patch.enabled;
|
|
183
|
-
if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
|
|
184
|
-
if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
|
|
185
|
-
if (patch.compatibilityProfile !== undefined)
|
|
186
|
-
set.compatibilityProfile = patch.compatibilityProfile;
|
|
187
|
-
const rows =
|
|
188
|
-
Object.keys(set).length > 0
|
|
189
|
-
? await this.db
|
|
190
|
-
.update(providers)
|
|
191
|
-
.set(set)
|
|
192
|
-
.where(
|
|
193
|
-
and(
|
|
194
|
-
eq(providers.id, providerId),
|
|
195
|
-
ownedByOnly(providers.tenantId, tenantId),
|
|
196
|
-
),
|
|
197
|
-
)
|
|
198
|
-
.returning()
|
|
199
|
-
: await this.db
|
|
200
|
-
.select()
|
|
201
|
-
.from(providers)
|
|
202
|
-
.where(
|
|
203
|
-
and(
|
|
204
|
-
eq(providers.id, providerId),
|
|
205
|
-
ownedByOnly(providers.tenantId, tenantId),
|
|
206
|
-
),
|
|
207
|
-
);
|
|
208
|
-
const row = rows[0];
|
|
209
|
-
return row ? mapProviderRow(row, this.providerRegistry) : undefined;
|
|
210
|
-
}
|
|
211
|
-
async delete(tenantId: string, providerId: string): Promise<boolean> {
|
|
212
|
-
if (isBundledProviderId(providerId))
|
|
213
|
-
throw new ConsoleDomainError(
|
|
214
|
-
"provider_is_builtin",
|
|
215
|
-
403,
|
|
216
|
-
"Built-in provider cannot be deleted",
|
|
217
|
-
);
|
|
218
|
-
// Custom-provider rows own their models, accounts, disabled-model flags,
|
|
219
|
-
// and routing preferences: deleting the parent cascades all of them
|
|
220
|
-
// (models/provider_accounts/provider_oauth_states via provider FK,
|
|
221
|
-
// health_events via account FK, tenant_disabled_models and
|
|
222
|
-
// provider_routing_settings via provider FK) in one atomic DELETE.
|
|
223
|
-
const deleted = await this.db
|
|
224
|
-
.delete(providers)
|
|
225
|
-
.where(and(eq(providers.id, providerId), ownedByOnly(providers.tenantId, tenantId)))
|
|
226
|
-
.returning({ id: providers.id });
|
|
227
|
-
if (deleted.length === 0) return false;
|
|
228
|
-
this.providerRegistry.unregister(providerId);
|
|
229
|
-
// The provider left the registry, so the compiled catalog is now missing it
|
|
230
|
-
// — the snapshot's re-seed is gated on that revision, and a delete that did
|
|
231
|
-
// not bump it would leave the provider's model rows behind.
|
|
232
|
-
bumpCatalogRevision();
|
|
233
|
-
return true;
|
|
234
|
-
}
|
|
235
|
-
async updateGlobal(
|
|
236
|
-
providerId: string,
|
|
237
|
-
patch: Partial<ProviderRecord>,
|
|
238
|
-
): Promise<ProviderRecord | undefined> {
|
|
239
|
-
const isBuiltin = isBundledProviderId(providerId);
|
|
240
|
-
if (isBuiltin && Object.keys(patch).some((field) => field !== "enabled")) {
|
|
241
|
-
throw new ConsoleDomainError(
|
|
242
|
-
"provider_is_builtin",
|
|
243
|
-
403,
|
|
244
|
-
"Built-in providers only support enable/disable",
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
|
|
248
|
-
const set: Partial<typeof providers.$inferInsert> = {};
|
|
249
|
-
if (patch.enabled !== undefined) set.enabled = patch.enabled;
|
|
250
|
-
if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
|
|
251
|
-
if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
|
|
252
|
-
if (patch.compatibilityProfile !== undefined) {
|
|
253
|
-
set.compatibilityProfile = patch.compatibilityProfile;
|
|
254
|
-
}
|
|
255
|
-
const rows =
|
|
256
|
-
Object.keys(set).length > 0
|
|
257
|
-
? await this.db
|
|
258
|
-
.update(providers)
|
|
259
|
-
.set(set)
|
|
260
|
-
.where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
|
|
261
|
-
.returning()
|
|
262
|
-
: await this.db
|
|
263
|
-
.select()
|
|
264
|
-
.from(providers)
|
|
265
|
-
.where(and(eq(providers.id, providerId), isNull(providers.tenantId)));
|
|
266
|
-
const row = rows[0];
|
|
267
|
-
return row ? mapProviderRow(row, this.providerRegistry) : undefined;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
async deleteGlobal(providerId: string): Promise<boolean> {
|
|
271
|
-
if (isBundledProviderId(providerId)) {
|
|
272
|
-
throw new ConsoleDomainError(
|
|
273
|
-
"provider_is_builtin",
|
|
274
|
-
403,
|
|
275
|
-
"Built-in provider cannot be deleted",
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
const rows = await this.db
|
|
279
|
-
.delete(providers)
|
|
280
|
-
.where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
|
|
281
|
-
.returning({ id: providers.id });
|
|
282
|
-
return rows.length > 0;
|
|
283
|
-
}
|
|
284
|
-
async listModels(tenantId: string, providerId: string): Promise<readonly ModelCatalogEntry[]> {
|
|
285
|
-
const byProvider = await this.listModelsForTenant(tenantId, providerId);
|
|
286
|
-
return byProvider.get(providerId) ?? [];
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Every enabled-and-disabled model row the tenant can see, grouped by
|
|
291
|
-
* provider. One pair of statements for the whole tenant instead of a pair per
|
|
292
|
-
* provider.
|
|
293
|
-
*
|
|
294
|
-
* The per-provider `listModels` shape costs two queries per call, so a caller
|
|
295
|
-
* that needs every provider's models — the flat catalog the picker renders —
|
|
296
|
-
* issued `2 * providers + 2` statements, growing with the bundled catalog.
|
|
297
|
-
* Grouping here makes it constant: one disabled-model read and one models
|
|
298
|
-
* join, regardless of provider count. `providerId` narrows it to a single
|
|
299
|
-
* provider for callers that already know which one they want.
|
|
300
|
-
*/
|
|
301
|
-
async listModelsForTenant(
|
|
302
|
-
tenantId: string,
|
|
303
|
-
providerId?: string,
|
|
304
|
-
): Promise<Map<string, readonly ModelCatalogEntry[]>> {
|
|
305
|
-
const disabled = await this.db
|
|
306
|
-
.select({
|
|
307
|
-
providerId: tenantDisabledModels.providerId,
|
|
308
|
-
modelId: tenantDisabledModels.modelId,
|
|
309
|
-
endpointPath: tenantDisabledModels.endpointPath,
|
|
310
|
-
})
|
|
311
|
-
.from(tenantDisabledModels)
|
|
312
|
-
.where(
|
|
313
|
-
providerId === undefined
|
|
314
|
-
? eq(tenantDisabledModels.tenantId, tenantId)
|
|
315
|
-
: and(
|
|
316
|
-
eq(tenantDisabledModels.tenantId, tenantId),
|
|
317
|
-
eq(tenantDisabledModels.providerId, providerId),
|
|
318
|
-
),
|
|
319
|
-
);
|
|
320
|
-
const disabledKeys = new Set(
|
|
321
|
-
disabled.map((d) => `${d.providerId}::${d.modelId}::${d.endpointPath}`),
|
|
322
|
-
);
|
|
323
|
-
const dbRows = await this.db
|
|
324
|
-
.select({ model: models })
|
|
325
|
-
.from(models)
|
|
326
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
327
|
-
.where(
|
|
328
|
-
and(
|
|
329
|
-
...(providerId === undefined ? [] : [eq(models.providerId, providerId)]),
|
|
330
|
-
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
331
|
-
),
|
|
332
|
-
);
|
|
333
|
-
const grouped = new Map<string, ModelCatalogEntry[]>();
|
|
334
|
-
for (const { model: row } of dbRows) {
|
|
335
|
-
const isDisabled = disabledKeys.has(
|
|
336
|
-
`${row.providerId}::${row.modelId}::${row.endpointPath}`,
|
|
337
|
-
);
|
|
338
|
-
const list = grouped.get(row.providerId) ?? [];
|
|
339
|
-
list.push(mapModelRow(row, isDisabled));
|
|
340
|
-
grouped.set(row.providerId, list);
|
|
341
|
-
}
|
|
342
|
-
return grouped;
|
|
343
|
-
}
|
|
344
|
-
async registerModels(
|
|
345
|
-
tenantId: string,
|
|
346
|
-
providerId: string,
|
|
347
|
-
modelIds: readonly string[],
|
|
348
|
-
wireFamily?: string,
|
|
349
|
-
metadata?: ModelMetadataOverride,
|
|
350
|
-
): Promise<void> {
|
|
351
|
-
const owner = await this.db
|
|
352
|
-
.select({
|
|
353
|
-
id: providers.id,
|
|
354
|
-
baseUrl: providers.baseUrl,
|
|
355
|
-
compatibilityProfile: providers.compatibilityProfile,
|
|
356
|
-
})
|
|
357
|
-
.from(providers)
|
|
358
|
-
.where(and(eq(providers.id, providerId), globalOrOwnedBy(providers.tenantId, tenantId)))
|
|
359
|
-
.limit(1);
|
|
360
|
-
if (!owner[0]) {
|
|
361
|
-
throw new ConsoleDomainError("provider_not_found", 404, `Provider ${providerId} not found`);
|
|
362
|
-
}
|
|
363
|
-
const family = (wireFamily ?? "chat") as WireFamily;
|
|
364
|
-
// Static endpoint from the provider's own bundled catalog via the probing
|
|
365
|
-
// service (the same startup-built map this store is constructed with).
|
|
366
|
-
// Without this, bundled providers on versioned bases (cline) get the
|
|
367
|
-
// generic `/v1/…` default, which joins into an unreachable path and every
|
|
368
|
-
// probe/dispatch 404s.
|
|
369
|
-
const staticEndpoint = await this.probing.resolveStaticEndpoint(providerId, family);
|
|
370
|
-
const endpointPath = endpointPathForProviderModel(
|
|
371
|
-
family,
|
|
372
|
-
owner[0].compatibilityProfile,
|
|
373
|
-
staticEndpoint === undefined ? undefined : { [family]: staticEndpoint },
|
|
374
|
-
);
|
|
375
|
-
if (modelIds.length === 0) return;
|
|
376
|
-
// Single multi-row insert and upsert of the capability flags so a
|
|
377
|
-
// re-registration repairs a row whose `tool_call` (and `reasoning`) was
|
|
378
|
-
// seeded false — a manual add persists schema defaults, which capability
|
|
379
|
-
// preflight reads as "route cannot use tools" and silently strips `tools`
|
|
380
|
-
// from every request. `enabled` stays operator-owned; `source` is refreshed
|
|
381
|
-
// to `manual`.
|
|
382
|
-
await this.db
|
|
383
|
-
.insert(models)
|
|
384
|
-
.values(
|
|
385
|
-
modelIds.map((modelId) => ({
|
|
386
|
-
providerId,
|
|
387
|
-
modelId,
|
|
388
|
-
wireFamily: family,
|
|
389
|
-
endpointPath,
|
|
390
|
-
...applyModelMetadataOverride(
|
|
391
|
-
resolveManualModelMetadata(providerId, modelId),
|
|
392
|
-
metadata,
|
|
393
|
-
),
|
|
394
|
-
source: "manual",
|
|
395
|
-
sourceUpdatedAt: new Date(),
|
|
396
|
-
enabled: true,
|
|
397
|
-
})),
|
|
398
|
-
)
|
|
399
|
-
.onConflictDoUpdate({
|
|
400
|
-
target: [models.providerId, models.modelId, models.endpointPath],
|
|
401
|
-
set: {
|
|
402
|
-
wireFamily: sql`excluded.wire_family`,
|
|
403
|
-
contextLimit: sql`excluded.context_limit`,
|
|
404
|
-
outputLimit: sql`excluded.output_limit`,
|
|
405
|
-
modalities: sql`excluded.modalities`,
|
|
406
|
-
reasoning: sql`excluded.reasoning`,
|
|
407
|
-
toolCall: sql`excluded.tool_call`,
|
|
408
|
-
source: sql`'manual'`,
|
|
409
|
-
sourceUpdatedAt: sql`excluded.source_updated_at`,
|
|
410
|
-
},
|
|
411
|
-
});
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
/**
|
|
415
|
-
* One-shot connectivity probe and model discovery live in
|
|
416
|
-
* `services/provider-probing.ts` — the store only forwards so the domain
|
|
417
|
-
* `ProviderCatalogStore` interface is unchanged.
|
|
418
|
-
*/
|
|
419
|
-
async probeModel(
|
|
420
|
-
tenantId: string,
|
|
421
|
-
providerId: string,
|
|
422
|
-
request: ProbeModelRequest,
|
|
423
|
-
): Promise<ProbeModelResult> {
|
|
424
|
-
return this.probing.probeModel(tenantId, providerId, request);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
/** Ad-hoc BYOK connectivity test; delegates to the shared probing service. */
|
|
428
|
-
async testByokConnection(
|
|
429
|
-
tenantId: string,
|
|
430
|
-
request: ByokConnectionTestRequest,
|
|
431
|
-
): Promise<ByokConnectionTestResult> {
|
|
432
|
-
return this.probing.testByokConnection(tenantId, request);
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
/**
|
|
436
|
-
* Batched provider-wide probe: the first registered model runs sequentially
|
|
437
|
-
* (warms OAuth token / connection pools), the rest run concurrently bounded
|
|
438
|
-
* to 5. Registered model ids come from this store's own catalog read.
|
|
439
|
-
*/
|
|
440
|
-
async probeAllModels(
|
|
441
|
-
tenantId: string,
|
|
442
|
-
providerId: string,
|
|
443
|
-
mode?: ProbeModelRequest["mode"],
|
|
444
|
-
): Promise<ProbeAllModelsResult> {
|
|
445
|
-
const entries = await this.listModels(tenantId, providerId);
|
|
446
|
-
const requests = entries.map((entry) => ({
|
|
447
|
-
modelId: entry.modelId,
|
|
448
|
-
route: entry.route,
|
|
449
|
-
serviceKind: isServiceKind(entry.serviceKind) ? entry.serviceKind : ("llm" as const),
|
|
450
|
-
...(mode === undefined ? {} : { mode }),
|
|
451
|
-
}));
|
|
452
|
-
const uniqueRequests = requests.filter(
|
|
453
|
-
(request, index, all) =>
|
|
454
|
-
all.findIndex(
|
|
455
|
-
(candidate) =>
|
|
456
|
-
candidate.modelId === request.modelId &&
|
|
457
|
-
candidate.route === request.route &&
|
|
458
|
-
candidate.serviceKind === request.serviceKind,
|
|
459
|
-
) === index,
|
|
460
|
-
);
|
|
461
|
-
return this.probing.probeAllModels(tenantId, providerId, uniqueRequests);
|
|
462
|
-
}
|
|
463
|
-
async probeAllAccounts(
|
|
464
|
-
tenantId: string,
|
|
465
|
-
providerId: string,
|
|
466
|
-
request: ProbeModelRequest,
|
|
467
|
-
): Promise<ProbeAllAccountsResult> {
|
|
468
|
-
return this.probing.probeAllAccounts(tenantId, providerId, request);
|
|
469
|
-
}
|
|
470
|
-
/** Toggles a persisted model's routing eligibility. */
|
|
471
|
-
async setModelEnabled(
|
|
472
|
-
tenantId: string,
|
|
473
|
-
providerId: string,
|
|
474
|
-
request: SetModelEnabledRequest,
|
|
475
|
-
): Promise<boolean> {
|
|
476
|
-
// Tenant-owned (BYOK) model: toggle the shared row directly.
|
|
477
|
-
const owned = await this.db
|
|
478
|
-
.select({ id: models.id })
|
|
479
|
-
.from(models)
|
|
480
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
481
|
-
.where(
|
|
482
|
-
and(
|
|
483
|
-
eq(models.providerId, providerId),
|
|
484
|
-
eq(models.modelId, request.modelId),
|
|
485
|
-
eq(models.endpointPath, request.route),
|
|
486
|
-
ownedByOnly(providers.tenantId, tenantId),
|
|
487
|
-
),
|
|
488
|
-
)
|
|
489
|
-
.limit(1);
|
|
490
|
-
const ownedTarget = owned[0];
|
|
491
|
-
if (ownedTarget) {
|
|
492
|
-
await this.db
|
|
493
|
-
.update(models)
|
|
494
|
-
.set({ enabled: request.enabled })
|
|
495
|
-
.where(eq(models.id, ownedTarget.id));
|
|
496
|
-
return true;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
// Global/built-in model: tenants cannot mutate the shared row, so record
|
|
500
|
-
// (or clear) a tenant-scoped disable in `tenantDisabledModels`.
|
|
501
|
-
const global = await this.db
|
|
502
|
-
.select({ id: models.id })
|
|
503
|
-
.from(models)
|
|
504
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
505
|
-
.where(
|
|
506
|
-
and(
|
|
507
|
-
eq(models.providerId, providerId),
|
|
508
|
-
eq(models.modelId, request.modelId),
|
|
509
|
-
eq(models.endpointPath, request.route),
|
|
510
|
-
isNull(providers.tenantId),
|
|
511
|
-
),
|
|
512
|
-
)
|
|
513
|
-
.limit(1);
|
|
514
|
-
if (global[0] === undefined) return false;
|
|
515
|
-
if (request.enabled) {
|
|
516
|
-
await this.db
|
|
517
|
-
.delete(tenantDisabledModels)
|
|
518
|
-
.where(
|
|
519
|
-
and(
|
|
520
|
-
eq(tenantDisabledModels.tenantId, tenantId),
|
|
521
|
-
eq(tenantDisabledModels.providerId, providerId),
|
|
522
|
-
eq(tenantDisabledModels.modelId, request.modelId),
|
|
523
|
-
eq(tenantDisabledModels.endpointPath, request.route),
|
|
524
|
-
),
|
|
525
|
-
);
|
|
526
|
-
return true;
|
|
527
|
-
}
|
|
528
|
-
await this.db
|
|
529
|
-
.insert(tenantDisabledModels)
|
|
530
|
-
.values({
|
|
531
|
-
tenantId,
|
|
532
|
-
providerId,
|
|
533
|
-
modelId: request.modelId,
|
|
534
|
-
endpointPath: request.route,
|
|
535
|
-
})
|
|
536
|
-
.onConflictDoNothing({
|
|
537
|
-
target: [
|
|
538
|
-
tenantDisabledModels.tenantId,
|
|
539
|
-
tenantDisabledModels.providerId,
|
|
540
|
-
tenantDisabledModels.modelId,
|
|
541
|
-
tenantDisabledModels.endpointPath,
|
|
542
|
-
],
|
|
543
|
-
});
|
|
544
|
-
return true;
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* Updates one model's limits and capability flags.
|
|
549
|
-
*
|
|
550
|
-
* Two row kinds are writable. A tenant-owned (BYOK) row is updated directly.
|
|
551
|
-
* A model belonging to a *global* provider row is also updated directly:
|
|
552
|
-
* bundled providers are seeded once with `tenant_id IS NULL` and `providers.id`
|
|
553
|
-
* is the primary key, so a bundled provider can only ever have that one row —
|
|
554
|
-
* there is no tenant-scoped row to edit instead. Auto-fetched rows
|
|
555
|
-
* (`discovered` / `auto_free`) live on exactly those providers, which is why
|
|
556
|
-
* the previous `ownedByOnly` filter answered `not_found` for every model the
|
|
557
|
-
* operator had just fetched and could not be edited at all.
|
|
558
|
-
*
|
|
559
|
-
* A global `builtin` row is rejected: it is re-seeded from the bundle on every
|
|
560
|
-
* boot, so an edit there would be silently reverted, and `deleteModel` refuses
|
|
561
|
-
* it for the same reason.
|
|
562
|
-
*
|
|
563
|
-
* Absent override fields leave the stored value untouched — an edit that only
|
|
564
|
-
* sets the context ceiling must not reset the capability flags.
|
|
565
|
-
*/
|
|
566
|
-
async updateModelMetadata(
|
|
567
|
-
tenantId: string,
|
|
568
|
-
providerId: string,
|
|
569
|
-
request: UpdateModelMetadataRequest,
|
|
570
|
-
): Promise<boolean> {
|
|
571
|
-
const findRow = (ownership: SQL) =>
|
|
572
|
-
this.db
|
|
573
|
-
.select({ id: models.id, modalities: models.modalities, source: models.source })
|
|
574
|
-
// source is read for both the global builtin guard above and the new
|
|
575
|
-
// non-builtin-only warning edit.
|
|
576
|
-
.from(models)
|
|
577
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
578
|
-
.where(
|
|
579
|
-
and(
|
|
580
|
-
eq(models.providerId, providerId),
|
|
581
|
-
eq(models.modelId, request.modelId),
|
|
582
|
-
eq(models.endpointPath, request.route),
|
|
583
|
-
ownership,
|
|
584
|
-
),
|
|
585
|
-
)
|
|
586
|
-
.limit(1);
|
|
587
|
-
const owned = await findRow(ownedByOnly(providers.tenantId, tenantId));
|
|
588
|
-
let row = owned[0];
|
|
589
|
-
if (row === undefined) {
|
|
590
|
-
const global = await findRow(isNull(providers.tenantId));
|
|
591
|
-
const candidate = global[0];
|
|
592
|
-
if (candidate === undefined) return false;
|
|
593
|
-
if (candidate.source === "builtin") {
|
|
594
|
-
throw new ConsoleDomainError(
|
|
595
|
-
"builtin_model_immutable",
|
|
596
|
-
403,
|
|
597
|
-
`Built-in model ${providerId}/${request.modelId} cannot be edited — the bundle re-seeds it on every boot`,
|
|
598
|
-
);
|
|
599
|
-
}
|
|
600
|
-
row = candidate;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
const override = request.metadata;
|
|
604
|
-
const set: Record<string, unknown> = { sourceUpdatedAt: new Date() };
|
|
605
|
-
if (override?.contextLimit !== undefined) set.contextLimit = override.contextLimit;
|
|
606
|
-
if (override?.outputLimit !== undefined) set.outputLimit = override.outputLimit;
|
|
607
|
-
// Passthrough grants every capability and cannot be partly revoked by a
|
|
608
|
-
// per-flag field: the point of the flag is that the operator does not know
|
|
609
|
-
// which capabilities the model has, so an explicit `false` alongside it is
|
|
610
|
-
// either stale or a mistake.
|
|
611
|
-
// Builtin rows are re-seeded from the static catalog on every boot, so a
|
|
612
|
-
// warning edit there would be reverted. Non-builtin rows (including ones a
|
|
613
|
-
// discovery commit created) are operator-owned and may set or clear them.
|
|
614
|
-
if (override?.warnings !== undefined) {
|
|
615
|
-
if (row.source === "builtin") {
|
|
616
|
-
throw new ConsoleDomainError(
|
|
617
|
-
"builtin_model_immutable",
|
|
618
|
-
403,
|
|
619
|
-
`Built-in model ${providerId}/${request.modelId} warnings cannot be edited — the bundle re-seeds them on every boot`,
|
|
620
|
-
);
|
|
621
|
-
}
|
|
622
|
-
set.warningMetadata =
|
|
623
|
-
override.warnings === null
|
|
624
|
-
? null
|
|
625
|
-
: {
|
|
626
|
-
warnings: override.warnings.map((warning) => ({
|
|
627
|
-
kind: warning.kind,
|
|
628
|
-
message: warning.message,
|
|
629
|
-
...(warning.severity === undefined ? {} : { severity: warning.severity }),
|
|
630
|
-
...(warning.updatedAt === undefined ? {} : { updatedAt: warning.updatedAt }),
|
|
631
|
-
})),
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
if (override?.passthrough === true) {
|
|
635
|
-
set.reasoning = true;
|
|
636
|
-
set.toolCall = true;
|
|
637
|
-
set.modalities = withImageModality(asStoredText(row.modalities), true);
|
|
638
|
-
} else {
|
|
639
|
-
if (override?.reasoning !== undefined) set.reasoning = override.reasoning;
|
|
640
|
-
if (override?.toolCall !== undefined) set.toolCall = override.toolCall;
|
|
641
|
-
if (override?.image !== undefined)
|
|
642
|
-
set.modalities = withImageModality(asStoredText(row.modalities), override.image);
|
|
643
|
-
}
|
|
644
|
-
await this.db.update(models).set(set).where(eq(models.id, row.id));
|
|
645
|
-
return true;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
async deleteModel(
|
|
649
|
-
tenantId: string,
|
|
650
|
-
providerId: string,
|
|
651
|
-
request: SetModelEnabledRequest,
|
|
652
|
-
): Promise<boolean> {
|
|
653
|
-
const owned = await this.db
|
|
654
|
-
.select({ id: models.id })
|
|
655
|
-
.from(models)
|
|
656
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
657
|
-
.where(
|
|
658
|
-
and(
|
|
659
|
-
eq(models.providerId, providerId),
|
|
660
|
-
eq(models.modelId, request.modelId),
|
|
661
|
-
eq(models.endpointPath, request.route),
|
|
662
|
-
ownedByOnly(providers.tenantId, tenantId),
|
|
663
|
-
),
|
|
664
|
-
)
|
|
665
|
-
.limit(1);
|
|
666
|
-
const target = owned[0];
|
|
667
|
-
if (target) {
|
|
668
|
-
const rows = await this.db
|
|
669
|
-
.delete(models)
|
|
670
|
-
.where(eq(models.id, target.id))
|
|
671
|
-
.returning({ id: models.id });
|
|
672
|
-
return rows.length > 0;
|
|
673
|
-
}
|
|
674
|
-
const global = await this.db
|
|
675
|
-
.select({ id: models.id, source: models.source })
|
|
676
|
-
.from(models)
|
|
677
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
678
|
-
.where(
|
|
679
|
-
and(
|
|
680
|
-
eq(models.providerId, providerId),
|
|
681
|
-
eq(models.modelId, request.modelId),
|
|
682
|
-
eq(models.endpointPath, request.route),
|
|
683
|
-
isNull(providers.tenantId),
|
|
684
|
-
),
|
|
685
|
-
)
|
|
686
|
-
.limit(1);
|
|
687
|
-
if (!global[0]) return false;
|
|
688
|
-
// Built-in models are immutable catalog: tenants can only disable them
|
|
689
|
-
// (via tenantDisabledModels), never delete the shared row.
|
|
690
|
-
if (global[0].source === "builtin") {
|
|
691
|
-
throw new ConsoleDomainError(
|
|
692
|
-
"builtin_model_immutable",
|
|
693
|
-
403,
|
|
694
|
-
`Built-in model ${providerId}/${request.modelId} cannot be deleted — disable it instead`,
|
|
695
|
-
);
|
|
696
|
-
}
|
|
697
|
-
const rows = await this.db
|
|
698
|
-
.delete(models)
|
|
699
|
-
.where(eq(models.id, global[0].id))
|
|
700
|
-
.returning({ id: models.id });
|
|
701
|
-
await this.db
|
|
702
|
-
.delete(tenantDisabledModels)
|
|
703
|
-
.where(
|
|
704
|
-
and(
|
|
705
|
-
eq(tenantDisabledModels.tenantId, tenantId),
|
|
706
|
-
eq(tenantDisabledModels.providerId, providerId),
|
|
707
|
-
eq(tenantDisabledModels.modelId, request.modelId),
|
|
708
|
-
eq(tenantDisabledModels.endpointPath, request.route),
|
|
709
|
-
),
|
|
710
|
-
);
|
|
711
|
-
return rows.length > 0;
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
private async accountsWithUsage(
|
|
715
|
-
tenantId: string,
|
|
716
|
-
rows: readonly (typeof providerAccounts.$inferSelect)[],
|
|
717
|
-
): Promise<readonly ProviderAccountResponse[]> {
|
|
718
|
-
if (rows.length === 0) return [];
|
|
719
|
-
const accountIds = rows.map((row) => row.id);
|
|
720
|
-
// Today's per-account usage is one GROUP BY, not a read of every event
|
|
721
|
-
// since midnight. The read is scoped to the accounts on this page via the
|
|
722
|
-
// `(tenant, account, created_at)` index — a full-tenant breakdown scanned
|
|
723
|
-
// every of today's events and capped at 100 groups, so an account past
|
|
724
|
-
// the cap silently read as "no usage today".
|
|
725
|
-
const todayGroups = await this.accountTodayUsage(tenantId, accountIds);
|
|
726
|
-
const todayByAccount = new Map(
|
|
727
|
-
todayGroups.map((group) => [
|
|
728
|
-
group.key,
|
|
729
|
-
{
|
|
730
|
-
requests: group.requests,
|
|
731
|
-
errors: group.errors,
|
|
732
|
-
inputTokens: group.inputTokens,
|
|
733
|
-
outputTokens: group.outputTokens,
|
|
734
|
-
},
|
|
735
|
-
]),
|
|
736
|
-
);
|
|
737
|
-
const [lifetimeRows] = await Promise.all([
|
|
738
|
-
this.db
|
|
739
|
-
.select()
|
|
740
|
-
.from(telemetryUsageTotals)
|
|
741
|
-
.where(
|
|
742
|
-
and(
|
|
743
|
-
eq(telemetryUsageTotals.tenantId, tenantId),
|
|
744
|
-
eq(telemetryUsageTotals.identityType, "account"),
|
|
745
|
-
inArray(telemetryUsageTotals.entityId, accountIds),
|
|
746
|
-
),
|
|
747
|
-
),
|
|
748
|
-
]);
|
|
749
|
-
const lifetimeByAccount = new Map(lifetimeRows.map((row) => [row.entityId, row]));
|
|
750
|
-
const mapUsage = (row: {
|
|
751
|
-
readonly requests: number | string | null;
|
|
752
|
-
readonly errors: number | string | null;
|
|
753
|
-
readonly inputTokens: number | string | null;
|
|
754
|
-
readonly outputTokens: number | string | null;
|
|
755
|
-
} | undefined): ProviderAccountTokenUsage => {
|
|
756
|
-
const inputTokens = Number(row?.inputTokens ?? 0);
|
|
757
|
-
const outputTokens = Number(row?.outputTokens ?? 0);
|
|
758
|
-
return {
|
|
759
|
-
requests: Number(row?.requests ?? 0),
|
|
760
|
-
errors: Number(row?.errors ?? 0),
|
|
761
|
-
inputTokens,
|
|
762
|
-
outputTokens,
|
|
763
|
-
totalTokens: inputTokens + outputTokens,
|
|
764
|
-
};
|
|
765
|
-
};
|
|
766
|
-
const first = rows[0];
|
|
767
|
-
const inflightByAccount = await this.accountInflightFor(tenantId, first?.providerId);
|
|
768
|
-
return rows.map((row) => {
|
|
769
|
-
const inflight = inflightByAccount.get(row.id);
|
|
770
|
-
return this.mapAccount(row, {
|
|
771
|
-
today: mapUsage(todayByAccount.get(row.id)),
|
|
772
|
-
allTime: mapUsage(lifetimeByAccount.get(row.id)),
|
|
773
|
-
...(inflight === undefined ? {} : { inflight }),
|
|
774
|
-
});
|
|
775
|
-
});
|
|
776
|
-
}
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* Today's per-account usage for a concrete id set. Scoped to those ids so
|
|
780
|
-
* the `(tenant, account, created_at)` index serves the range instead of a
|
|
781
|
-
* full-tenant window scan; returns exactly one group per account with
|
|
782
|
-
* traffic, so an account is never lost to a top-N cap.
|
|
783
|
-
*/
|
|
784
|
-
private async accountTodayUsage(
|
|
785
|
-
tenantId: string,
|
|
786
|
-
accountIds: readonly string[],
|
|
787
|
-
): Promise<
|
|
788
|
-
Array<{ key: string; requests: number; errors: number; inputTokens: number; outputTokens: number }>
|
|
789
|
-
> {
|
|
790
|
-
if (accountIds.length === 0) return [];
|
|
791
|
-
const todayStart = new Date();
|
|
792
|
-
todayStart.setUTCHours(0, 0, 0, 0);
|
|
793
|
-
const rows = await this.db
|
|
794
|
-
.select({
|
|
795
|
-
accountId: telemetryEvents.accountId,
|
|
796
|
-
requests: sql<number>`count(*)`,
|
|
797
|
-
errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
|
|
798
|
-
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
799
|
-
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
800
|
-
})
|
|
801
|
-
.from(telemetryEvents)
|
|
802
|
-
.where(
|
|
803
|
-
and(
|
|
804
|
-
eq(telemetryEvents.tenantId, tenantId),
|
|
805
|
-
gte(telemetryEvents.createdAt, todayStart),
|
|
806
|
-
inArray(telemetryEvents.accountId, accountIds),
|
|
807
|
-
),
|
|
808
|
-
)
|
|
809
|
-
.groupBy(telemetryEvents.accountId);
|
|
810
|
-
return rows.flatMap((row) => {
|
|
811
|
-
if (row.accountId === null) return [];
|
|
812
|
-
return [
|
|
813
|
-
{
|
|
814
|
-
key: row.accountId,
|
|
815
|
-
requests: Number(row.requests),
|
|
816
|
-
errors: Number(row.errors),
|
|
817
|
-
inputTokens: Number(row.inputTokens),
|
|
818
|
-
outputTokens: Number(row.outputTokens),
|
|
819
|
-
},
|
|
820
|
-
];
|
|
821
|
-
});
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
private async accountInflightFor(
|
|
825
|
-
tenantId: string,
|
|
826
|
-
providerId: string | undefined,
|
|
827
|
-
): Promise<Map<string, number>> {
|
|
828
|
-
const readings = new Map<string, number>();
|
|
829
|
-
if (!this.readAccountInflight || !providerId) return readings;
|
|
830
|
-
const rows = await this.readAccountInflight(providerId, tenantId);
|
|
831
|
-
for (const row of rows) {
|
|
832
|
-
if (typeof row.accountId !== "string" || !Number.isFinite(row.inflight)) continue;
|
|
833
|
-
readings.set(row.accountId, Math.max(0, Math.floor(row.inflight)));
|
|
834
|
-
}
|
|
835
|
-
return readings;
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/**
|
|
839
|
-
* Next free list position for a new account in this provider's list.
|
|
840
|
-
*
|
|
841
|
-
* Scoped to (provider, tenant) because the list is per provider: a tenant
|
|
842
|
-
* with accounts under two providers must not have the second provider's
|
|
843
|
-
* first account land at a high index.
|
|
844
|
-
*/
|
|
845
|
-
/**
|
|
846
|
-
* Labels already in use for one provider, for numbering a generated name.
|
|
847
|
-
*
|
|
848
|
-
* Scoped to this provider and tenant: `dcint-key-1` and `other-key-1` belong
|
|
849
|
-
* to different providers and must not collide with each other.
|
|
850
|
-
*/
|
|
851
|
-
private async generateAccountLabel(
|
|
852
|
-
db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
853
|
-
providerId: string,
|
|
854
|
-
tenantId: string,
|
|
855
|
-
credentialMode: CredentialMode,
|
|
856
|
-
): Promise<string> {
|
|
857
|
-
const rows = await db
|
|
858
|
-
.select({ label: providerAccounts.label })
|
|
859
|
-
.from(providerAccounts)
|
|
860
|
-
.where(
|
|
861
|
-
and(
|
|
862
|
-
eq(providerAccounts.providerId, providerId),
|
|
863
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
864
|
-
),
|
|
865
|
-
);
|
|
866
|
-
return nextGeneratedAccountLabel(
|
|
867
|
-
providerId,
|
|
868
|
-
credentialMode,
|
|
869
|
-
rows.map((row) => row.label).filter((label): label is string => typeof label === "string"),
|
|
870
|
-
);
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
private async nextAccountSortIndex(
|
|
874
|
-
db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
875
|
-
providerId: string,
|
|
876
|
-
tenantId: string,
|
|
877
|
-
): Promise<number> {
|
|
878
|
-
return nextSortIndex(
|
|
879
|
-
db,
|
|
880
|
-
{ table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
|
|
881
|
-
and(
|
|
882
|
-
eq(providerAccounts.providerId, providerId),
|
|
883
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
884
|
-
),
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/** Rewrites one provider's account order; `accountIds` is the full order. */
|
|
889
|
-
async reorderAccounts(
|
|
890
|
-
tenantId: string,
|
|
891
|
-
providerId: string,
|
|
892
|
-
accountIds: readonly string[],
|
|
893
|
-
): Promise<void> {
|
|
894
|
-
await reorderSortIndex(
|
|
895
|
-
this.db,
|
|
896
|
-
{ table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
|
|
897
|
-
and(
|
|
898
|
-
eq(providerAccounts.providerId, providerId),
|
|
899
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
900
|
-
),
|
|
901
|
-
accountIds,
|
|
902
|
-
);
|
|
903
|
-
}
|
|
904
|
-
|
|
905
|
-
async listAccountAuthStates(
|
|
906
|
-
tenantId: string,
|
|
907
|
-
providerId: string,
|
|
908
|
-
accountIds: readonly string[],
|
|
909
|
-
): Promise<Readonly<Record<string, Readonly<Record<string, unknown>>>>> {
|
|
910
|
-
if (accountIds.length === 0) return {};
|
|
911
|
-
const rows = await this.db
|
|
912
|
-
.select({ id: providerAccounts.id, authState: providerAccounts.authState })
|
|
913
|
-
.from(providerAccounts)
|
|
914
|
-
.where(
|
|
915
|
-
and(
|
|
916
|
-
eq(providerAccounts.providerId, providerId),
|
|
917
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
918
|
-
inArray(providerAccounts.id, accountIds),
|
|
919
|
-
),
|
|
920
|
-
);
|
|
921
|
-
const out: Record<string, Readonly<Record<string, unknown>>> = {};
|
|
922
|
-
for (const row of rows) {
|
|
923
|
-
if (row.authState !== null && typeof row.authState === "object" && !Array.isArray(row.authState)) {
|
|
924
|
-
out[row.id] = row.authState as Record<string, unknown>;
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
return out;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
async listAccounts(
|
|
931
|
-
tenantId: string,
|
|
932
|
-
providerId: string,
|
|
933
|
-
): Promise<readonly ProviderAccountResponse[]> {
|
|
934
|
-
const rows = await this.db
|
|
935
|
-
.select()
|
|
936
|
-
.from(providerAccounts)
|
|
937
|
-
.where(
|
|
938
|
-
and(
|
|
939
|
-
eq(providerAccounts.providerId, providerId),
|
|
940
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
941
|
-
),
|
|
942
|
-
)
|
|
943
|
-
// Stable list position, not `created_at`: two accounts created in the
|
|
944
|
-
// same millisecond could swap between loads. `id` breaks a tie so the
|
|
945
|
-
// order is total even before a reorder assigns distinct indices.
|
|
946
|
-
.orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
|
|
947
|
-
const accounts = await this.accountsWithUsage(tenantId, rows);
|
|
948
|
-
const poolAccounts = await this.listPoolAccounts(tenantId, providerId);
|
|
949
|
-
if (poolAccounts.length === 0) return accounts;
|
|
950
|
-
// Pool-as-account rows are appended after real accounts. They are
|
|
951
|
-
// account *equivalents* for ip-scoped, credential-less providers, so the
|
|
952
|
-
// operator sees the egresses that carry those providers' rate limits in
|
|
953
|
-
// the same list that already explains health.
|
|
954
|
-
return [...accounts, ...poolAccounts];
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
/**
|
|
958
|
-
* One cheap id-only read of an account set, for callers that need the set
|
|
959
|
-
* (e.g. the routing-inflight reader) without the usage aggregates
|
|
960
|
-
* `listAccounts` computes (P-02). No ORDER BY: the set is order-insensitive.
|
|
961
|
-
*/
|
|
962
|
-
async listAccountIds(
|
|
963
|
-
tenantId: string,
|
|
964
|
-
providerId: string,
|
|
965
|
-
): Promise<readonly string[]> {
|
|
966
|
-
const rows = await this.db
|
|
967
|
-
.select({ id: providerAccounts.id })
|
|
968
|
-
.from(providerAccounts)
|
|
969
|
-
.where(
|
|
970
|
-
and(
|
|
971
|
-
eq(providerAccounts.providerId, providerId),
|
|
972
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
973
|
-
),
|
|
974
|
-
);
|
|
975
|
-
return rows.map((row) => row.id);
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
async listAllAccounts(tenantId: string): Promise<readonly ProviderAccountResponse[]> {
|
|
979
|
-
const rows = await this.db
|
|
980
|
-
.select()
|
|
981
|
-
.from(providerAccounts)
|
|
982
|
-
.where(globalOrOwnedBy(providerAccounts.tenantId, tenantId))
|
|
983
|
-
.orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
|
|
984
|
-
const accounts = await this.accountsWithUsage(tenantId, rows);
|
|
985
|
-
const poolAccounts = await this.listAllPoolAccounts(tenantId);
|
|
986
|
-
if (poolAccounts.length === 0) return accounts;
|
|
987
|
-
return [...accounts, ...poolAccounts];
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
async listAllAccountSummaries(
|
|
991
|
-
tenantId: string,
|
|
992
|
-
): Promise<readonly { providerId: string; status: string }[]> {
|
|
993
|
-
const rows = await this.db
|
|
994
|
-
.select({ providerId: providerAccounts.providerId, status: providerAccounts.status })
|
|
995
|
-
.from(providerAccounts)
|
|
996
|
-
.where(globalOrOwnedBy(providerAccounts.tenantId, tenantId));
|
|
997
|
-
return rows.map((row) => ({ providerId: row.providerId, status: row.status }));
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
|
-
/**
|
|
1001
|
-
* Per-provider account totals for one tenant, from a single grouped read (one
|
|
1002
|
-
* statement, served by the `(provider_id, tenant_id)` index). `active` counts
|
|
1003
|
-
* every status except `disabled` — an account parked by the health machine
|
|
1004
|
-
* (`cooldown`) is still a connection, matching how the account-overview badge
|
|
1005
|
-
* reads status. Used by the provider list overview (`accountCount` /
|
|
1006
|
-
* `activeAccountCount`); totals are never counted in a per-provider loop.
|
|
1007
|
-
*/
|
|
1008
|
-
async providerAccountCounts(
|
|
1009
|
-
tenantId: string,
|
|
1010
|
-
): Promise<readonly { providerId: string; total: number; active: number }[]> {
|
|
1011
|
-
const rows = await this.db
|
|
1012
|
-
.select({
|
|
1013
|
-
providerId: providerAccounts.providerId,
|
|
1014
|
-
total: sql<number>`count(*)`,
|
|
1015
|
-
active: sql<number>`sum(case when ${providerAccounts.status} <> 'disabled' then 1 else 0 end)`,
|
|
1016
|
-
})
|
|
1017
|
-
.from(providerAccounts)
|
|
1018
|
-
.where(globalOrOwnedBy(providerAccounts.tenantId, tenantId))
|
|
1019
|
-
.groupBy(providerAccounts.providerId);
|
|
1020
|
-
return rows.map((row) => ({
|
|
1021
|
-
providerId: row.providerId,
|
|
1022
|
-
total: Number(row.total),
|
|
1023
|
-
active: Number(row.active),
|
|
1024
|
-
}));
|
|
1025
|
-
}
|
|
1026
|
-
|
|
1027
|
-
/**
|
|
1028
|
-
* Per-provider enabled model-row counts for one tenant, from a single grouped
|
|
1029
|
-
* read over `models ⋈ providers`. A model counts only when the row is enabled
|
|
1030
|
-
* and the tenant has not suppressed it via `tenant_disabled_models` — the
|
|
1031
|
-
* same visibility rule the per-provider model listing applies, so the
|
|
1032
|
-
* overview badge never disagrees with what the detail page would list.
|
|
1033
|
-
*/
|
|
1034
|
-
async providerModelCounts(
|
|
1035
|
-
tenantId: string,
|
|
1036
|
-
): Promise<readonly { providerId: string; count: number }[]> {
|
|
1037
|
-
const rows = await this.db
|
|
1038
|
-
.select({ providerId: models.providerId, count: sql<number>`count(*)` })
|
|
1039
|
-
.from(models)
|
|
1040
|
-
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
1041
|
-
.leftJoin(
|
|
1042
|
-
tenantDisabledModels,
|
|
1043
|
-
and(
|
|
1044
|
-
eq(tenantDisabledModels.tenantId, tenantId),
|
|
1045
|
-
eq(tenantDisabledModels.providerId, models.providerId),
|
|
1046
|
-
eq(tenantDisabledModels.modelId, models.modelId),
|
|
1047
|
-
eq(tenantDisabledModels.endpointPath, models.endpointPath),
|
|
1048
|
-
),
|
|
1049
|
-
)
|
|
1050
|
-
.where(
|
|
1051
|
-
and(
|
|
1052
|
-
eq(models.enabled, true),
|
|
1053
|
-
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
1054
|
-
isNull(tenantDisabledModels.id),
|
|
1055
|
-
),
|
|
1056
|
-
)
|
|
1057
|
-
.groupBy(models.providerId);
|
|
1058
|
-
return rows.map((row) => ({ providerId: row.providerId, count: Number(row.count) }));
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
/**
|
|
1062
|
-
* Pool-as-account rows for one provider. Only providers whose rate limit
|
|
1063
|
-
* follows the egress address materialize these; every other provider's pool
|
|
1064
|
-
* list stays empty, so real accounts are returned unchanged.
|
|
1065
|
-
*
|
|
1066
|
-
* The pool row is the source of health truth: `provider_pool_accounts`
|
|
1067
|
-
* rows are created at backfill and never mutated, so the surface reads the
|
|
1068
|
-
* owning pool's live status/last-* fields and lives on top of them.
|
|
1069
|
-
*/
|
|
1070
|
-
private async listPoolAccounts(
|
|
1071
|
-
tenantId: string,
|
|
1072
|
-
providerId: string,
|
|
1073
|
-
): Promise<readonly ProviderAccountResponse[]> {
|
|
1074
|
-
if (!providerRateLimitIsIpScoped(providerId)) return [];
|
|
1075
|
-
const rows = await this.db
|
|
1076
|
-
.select()
|
|
1077
|
-
.from(providerPoolAccounts)
|
|
1078
|
-
.where(
|
|
1079
|
-
and(
|
|
1080
|
-
eq(providerPoolAccounts.providerId, providerId),
|
|
1081
|
-
eq(providerPoolAccounts.tenantId, tenantId),
|
|
1082
|
-
),
|
|
1083
|
-
)
|
|
1084
|
-
.orderBy(asc(providerPoolAccounts.createdAt), asc(providerPoolAccounts.id));
|
|
1085
|
-
if (rows.length === 0) return [];
|
|
1086
|
-
const pools = await this.poolsById(tenantId, rows.map((row) => row.networkPoolId));
|
|
1087
|
-
const usage = await this.poolUsage(tenantId, rows.map((row) => row.networkPoolId));
|
|
1088
|
-
return rows.map((row) => this.mapPoolAccount(row, pools.get(row.networkPoolId), usage.get(row.networkPoolId)));
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
/** Pool-as-account rows across every provider, for tenant-wide listings. */
|
|
1092
|
-
private async listAllPoolAccounts(
|
|
1093
|
-
tenantId: string,
|
|
1094
|
-
): Promise<readonly ProviderAccountResponse[]> {
|
|
1095
|
-
const rows = await this.db
|
|
1096
|
-
.select()
|
|
1097
|
-
.from(providerPoolAccounts)
|
|
1098
|
-
.where(eq(providerPoolAccounts.tenantId, tenantId))
|
|
1099
|
-
.orderBy(asc(providerPoolAccounts.createdAt), asc(providerPoolAccounts.id));
|
|
1100
|
-
if (rows.length === 0) return [];
|
|
1101
|
-
const pools = await this.poolsById(tenantId, rows.map((row) => row.networkPoolId));
|
|
1102
|
-
const usage = await this.poolUsage(tenantId, rows.map((row) => row.networkPoolId));
|
|
1103
|
-
return rows.map((row) => this.mapPoolAccount(row, pools.get(row.networkPoolId), usage.get(row.networkPoolId)));
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
/** Selects the owning network pools for a set of pool-account rows. */
|
|
1107
|
-
private async poolsById(
|
|
1108
|
-
tenantId: string,
|
|
1109
|
-
poolIds: readonly string[],
|
|
1110
|
-
): Promise<Map<string, typeof networkPools.$inferSelect>> {
|
|
1111
|
-
const map = new Map<string, typeof networkPools.$inferSelect>();
|
|
1112
|
-
if (poolIds.length === 0) return map;
|
|
1113
|
-
const rows = await this.db
|
|
1114
|
-
.select()
|
|
1115
|
-
.from(networkPools)
|
|
1116
|
-
.where(
|
|
1117
|
-
and(
|
|
1118
|
-
eq(networkPools.tenantId, tenantId),
|
|
1119
|
-
inArray(networkPools.id, poolIds),
|
|
1120
|
-
),
|
|
1121
|
-
);
|
|
1122
|
-
for (const row of rows) map.set(row.id, row);
|
|
1123
|
-
return map;
|
|
1124
|
-
}
|
|
1125
|
-
|
|
1126
|
-
/**
|
|
1127
|
-
* Today + lifetime token usage per pool, from telemetry. Pools carry no
|
|
1128
|
-
* rows in `telemetry_usage_totals` (that table only admits account/api_key
|
|
1129
|
-
* identity types), so lifetime here is aggregated from the retained event
|
|
1130
|
-
* window (the same bounded window the usage page reads) rather than from
|
|
1131
|
-
* durable rollups.
|
|
1132
|
-
*/
|
|
1133
|
-
private async poolUsage(
|
|
1134
|
-
tenantId: string,
|
|
1135
|
-
poolIds: readonly string[],
|
|
1136
|
-
): Promise<Map<string, { today: ProviderAccountTokenUsage; allTime: ProviderAccountTokenUsage }>> {
|
|
1137
|
-
const map = new Map<string, { today: ProviderAccountTokenUsage; allTime: ProviderAccountTokenUsage }>();
|
|
1138
|
-
if (poolIds.length === 0) return map;
|
|
1139
|
-
const todayStart = new Date();
|
|
1140
|
-
todayStart.setUTCHours(0, 0, 0, 0);
|
|
1141
|
-
const [todayRows, lifetimeRows] = await Promise.all([
|
|
1142
|
-
this.db
|
|
1143
|
-
.select({
|
|
1144
|
-
poolId: telemetryEvents.networkPoolId,
|
|
1145
|
-
requests: sql<number>`count(*)`,
|
|
1146
|
-
errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
|
|
1147
|
-
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
1148
|
-
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
1149
|
-
})
|
|
1150
|
-
.from(telemetryEvents)
|
|
1151
|
-
.where(
|
|
1152
|
-
and(
|
|
1153
|
-
eq(telemetryEvents.tenantId, tenantId),
|
|
1154
|
-
gte(telemetryEvents.createdAt, todayStart),
|
|
1155
|
-
inArray(telemetryEvents.networkPoolId, poolIds),
|
|
1156
|
-
),
|
|
1157
|
-
)
|
|
1158
|
-
.groupBy(telemetryEvents.networkPoolId),
|
|
1159
|
-
this.db
|
|
1160
|
-
.select({
|
|
1161
|
-
poolId: telemetryEvents.networkPoolId,
|
|
1162
|
-
requests: sql<number>`count(*)`,
|
|
1163
|
-
errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
|
|
1164
|
-
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
1165
|
-
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
1166
|
-
})
|
|
1167
|
-
.from(telemetryEvents)
|
|
1168
|
-
.where(
|
|
1169
|
-
and(
|
|
1170
|
-
eq(telemetryEvents.tenantId, tenantId),
|
|
1171
|
-
inArray(telemetryEvents.networkPoolId, poolIds),
|
|
1172
|
-
),
|
|
1173
|
-
)
|
|
1174
|
-
.groupBy(telemetryEvents.networkPoolId),
|
|
1175
|
-
]);
|
|
1176
|
-
const toUsage = (row: {
|
|
1177
|
-
readonly requests: number | string | null;
|
|
1178
|
-
readonly errors: number | string | null;
|
|
1179
|
-
readonly inputTokens: number | string | null;
|
|
1180
|
-
readonly outputTokens: number | string | null;
|
|
1181
|
-
}): ProviderAccountTokenUsage => {
|
|
1182
|
-
const inputTokens = Number(row.inputTokens ?? 0);
|
|
1183
|
-
const outputTokens = Number(row.outputTokens ?? 0);
|
|
1184
|
-
return {
|
|
1185
|
-
requests: Number(row.requests ?? 0),
|
|
1186
|
-
errors: Number(row.errors ?? 0),
|
|
1187
|
-
inputTokens,
|
|
1188
|
-
outputTokens,
|
|
1189
|
-
totalTokens: inputTokens + outputTokens,
|
|
1190
|
-
};
|
|
1191
|
-
};
|
|
1192
|
-
for (const id of poolIds) {
|
|
1193
|
-
const today = todayRows.find((row) => row.poolId === id);
|
|
1194
|
-
const lifetime = lifetimeRows.find((row) => row.poolId === id);
|
|
1195
|
-
map.set(id, {
|
|
1196
|
-
today: today ? toUsage(today) : emptyUsage,
|
|
1197
|
-
allTime: lifetime ? toUsage(lifetime) : emptyUsage,
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
return map;
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
private mapPoolAccount(
|
|
1204
|
-
row: typeof providerPoolAccounts.$inferSelect,
|
|
1205
|
-
pool: typeof networkPools.$inferSelect | undefined,
|
|
1206
|
-
usage: { today: ProviderAccountTokenUsage; allTime: ProviderAccountTokenUsage } | undefined,
|
|
1207
|
-
): ProviderAccountResponse {
|
|
1208
|
-
return {
|
|
1209
|
-
id: row.id,
|
|
1210
|
-
providerId: row.providerId,
|
|
1211
|
-
tenantId: row.tenantId,
|
|
1212
|
-
label: row.label,
|
|
1213
|
-
credentialKind: "none",
|
|
1214
|
-
// Health truth lives on the owning pool; fall back to the row only when
|
|
1215
|
-
// the pool row is unreachable (deleted cascade would have removed the
|
|
1216
|
-
// account row, so this is defensive).
|
|
1217
|
-
status: pool?.status ?? row.status,
|
|
1218
|
-
usageToday: usage?.today ?? emptyUsage,
|
|
1219
|
-
usageAllTime: usage?.allTime ?? emptyUsage,
|
|
1220
|
-
consecutiveFailures: pool?.consecutiveFailures ?? row.consecutiveFailures,
|
|
1221
|
-
...(pool?.lastSuccessAt
|
|
1222
|
-
? { lastSuccessAt: pool.lastSuccessAt.toISOString() }
|
|
1223
|
-
: row.lastSuccessAt
|
|
1224
|
-
? { lastSuccessAt: row.lastSuccessAt.toISOString() }
|
|
1225
|
-
: {}),
|
|
1226
|
-
...(pool?.lastError ? { lastError: pool.lastError } : row.lastError ? { lastError: row.lastError } : {}),
|
|
1227
|
-
...(pool?.lastErrorCategory
|
|
1228
|
-
? { lastErrorCategory: pool.lastErrorCategory }
|
|
1229
|
-
: row.lastErrorCategory
|
|
1230
|
-
? { lastErrorCategory: row.lastErrorCategory }
|
|
1231
|
-
: {}),
|
|
1232
|
-
...(pool?.lastErrorAt
|
|
1233
|
-
? { lastErrorAt: pool.lastErrorAt.toISOString() }
|
|
1234
|
-
: row.lastErrorAt
|
|
1235
|
-
? { lastErrorAt: row.lastErrorAt.toISOString() }
|
|
1236
|
-
: {}),
|
|
1237
|
-
...(pool?.cooldownUntil
|
|
1238
|
-
? { cooldownUntil: pool.cooldownUntil.toISOString() }
|
|
1239
|
-
: row.cooldownUntil
|
|
1240
|
-
? { cooldownUntil: row.cooldownUntil.toISOString() }
|
|
1241
|
-
: {}),
|
|
1242
|
-
...(pool?.lastRecoveredAt
|
|
1243
|
-
? { lastRecoveredAt: pool.lastRecoveredAt.toISOString() }
|
|
1244
|
-
: row.lastRecoveredAt
|
|
1245
|
-
? { lastRecoveredAt: row.lastRecoveredAt.toISOString() }
|
|
1246
|
-
: {}),
|
|
1247
|
-
createdAt: row.createdAt.toISOString(),
|
|
1248
|
-
sortIndex: 0,
|
|
1249
|
-
kind: "pool",
|
|
1250
|
-
networkPoolId: row.networkPoolId,
|
|
1251
|
-
...(pool?.providerCooldowns
|
|
1252
|
-
? { providerCooldowns: activePoolProviderCooldowns(pool.providerCooldowns) }
|
|
1253
|
-
: {}),
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
async createAccount(
|
|
1258
|
-
tenantId: string,
|
|
1259
|
-
providerId: string,
|
|
1260
|
-
request: CreateProviderAccountRequest,
|
|
1261
|
-
existingTx?: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
1262
|
-
includeUsage = true,
|
|
1263
|
-
sortIndexOverride?: number,
|
|
1264
|
-
): Promise<ProviderAccountResponse> {
|
|
1265
|
-
// Callers inside an existing transaction pass it in so the account lands in
|
|
1266
|
-
// that unit of work (the batch path); otherwise this opens its own.
|
|
1267
|
-
const run = async (
|
|
1268
|
-
tx: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
1269
|
-
): Promise<ProviderAccountResponse> => {
|
|
1270
|
-
let effectiveSecret = request.secret;
|
|
1271
|
-
let effectiveCredentialKind = request.credentialKind;
|
|
1272
|
-
const requestedCredentialMode: CredentialMode = request.credentialMode ?? "auto";
|
|
1273
|
-
let effectiveCredentialMode: CredentialMode = requestedCredentialMode;
|
|
1274
|
-
let tokenExpiresAt: Date | undefined;
|
|
1275
|
-
let jwtIssuer: string | undefined;
|
|
1276
|
-
let oauthRefreshCiphertext: Buffer | undefined;
|
|
1277
|
-
let oauthExpiresAt: Date | undefined;
|
|
1278
|
-
let oauthHasRefreshToken = false;
|
|
1279
|
-
let pastedAuthState: Readonly<Record<string, unknown>> = {};
|
|
1280
|
-
|
|
1281
|
-
if (
|
|
1282
|
-
(request.credentialKind === "oauth" || request.credentialKind === "api_key") &&
|
|
1283
|
-
request.secret.trim().length > 0
|
|
1284
|
-
) {
|
|
1285
|
-
const trimmed = request.secret.trim();
|
|
1286
|
-
const parts = parsePastedOAuthCredential(trimmed);
|
|
1287
|
-
pastedAuthState = parts.authState;
|
|
1288
|
-
const hasRefreshToken = parts.refreshToken !== parts.accessToken;
|
|
1289
|
-
const parsedJwt =
|
|
1290
|
-
requestedCredentialMode === "api_key"
|
|
1291
|
-
? undefined
|
|
1292
|
-
: parseJwtCredential(parts.accessToken);
|
|
1293
|
-
if (requestedCredentialMode === "jwt" && parsedJwt === undefined) {
|
|
1294
|
-
throw new ConsoleDomainError(
|
|
1295
|
-
"invalid_request",
|
|
1296
|
-
400,
|
|
1297
|
-
"credentialMode jwt requires a valid three-part JWT",
|
|
1298
|
-
);
|
|
1299
|
-
}
|
|
1300
|
-
if (requestedCredentialMode === "api_key") {
|
|
1301
|
-
effectiveSecret = parts.accessToken;
|
|
1302
|
-
effectiveCredentialKind = "api_key";
|
|
1303
|
-
effectiveCredentialMode = "api_key";
|
|
1304
|
-
} else if (
|
|
1305
|
-
parsedJwt !== undefined &&
|
|
1306
|
-
(requestedCredentialMode === "jwt" || !hasRefreshToken)
|
|
1307
|
-
) {
|
|
1308
|
-
effectiveSecret = parts.accessToken;
|
|
1309
|
-
effectiveCredentialKind = "api_key";
|
|
1310
|
-
effectiveCredentialMode = "jwt";
|
|
1311
|
-
tokenExpiresAt = parsedJwt.expiresAt;
|
|
1312
|
-
// Kept alongside the expiry: the console shows who issued a static
|
|
1313
|
-
// token, and the secret itself is never readable after this point.
|
|
1314
|
-
jwtIssuer = parsedJwt.issuer;
|
|
1315
|
-
} else if (request.credentialKind === "oauth" || hasRefreshToken) {
|
|
1316
|
-
effectiveSecret = parts.accessToken;
|
|
1317
|
-
effectiveCredentialKind = hasRefreshToken ? "oauth" : "api_key";
|
|
1318
|
-
effectiveCredentialMode = hasRefreshToken ? "auto" : "api_key";
|
|
1319
|
-
if (hasRefreshToken) {
|
|
1320
|
-
oauthRefreshCiphertext = encryptCredential(parts.refreshToken);
|
|
1321
|
-
oauthHasRefreshToken = true;
|
|
1322
|
-
}
|
|
1323
|
-
oauthExpiresAt = parts.expiresAt;
|
|
1324
|
-
} else {
|
|
1325
|
-
effectiveCredentialMode = "api_key";
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// Append to the end of this provider's list rather than relying on
|
|
1330
|
-
// creation time, so a new account never displaces existing positions.
|
|
1331
|
-
const credentialFingerprint =
|
|
1332
|
-
effectiveCredentialKind === "none" || effectiveSecret.length === 0
|
|
1333
|
-
? undefined
|
|
1334
|
-
: hashSecret(effectiveSecret);
|
|
1335
|
-
const accountAuthState = {
|
|
1336
|
-
...pastedAuthState,
|
|
1337
|
-
...(request.authState ?? {}),
|
|
1338
|
-
credentialMode: effectiveCredentialMode,
|
|
1339
|
-
...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt: tokenExpiresAt.toISOString() }),
|
|
1340
|
-
...(jwtIssuer === undefined ? {} : { jwtIssuer }),
|
|
1341
|
-
};
|
|
1342
|
-
const nextSortIndex =
|
|
1343
|
-
sortIndexOverride ?? (await this.nextAccountSortIndex(tx, providerId, tenantId));
|
|
1344
|
-
// Read existing labels only when one has to be generated: a named
|
|
1345
|
-
// account costs no extra query, and this is the path a large batch
|
|
1346
|
-
// paste walks once per row.
|
|
1347
|
-
const label =
|
|
1348
|
-
request.label ??
|
|
1349
|
-
(await this.generateAccountLabel(tx, providerId, tenantId, effectiveCredentialMode));
|
|
1350
|
-
const rows = await tx
|
|
1351
|
-
.insert(providerAccounts)
|
|
1352
|
-
.values({
|
|
1353
|
-
providerId,
|
|
1354
|
-
tenantId,
|
|
1355
|
-
sortIndex: nextSortIndex,
|
|
1356
|
-
label,
|
|
1357
|
-
credentialCiphertext: encryptCredential(effectiveSecret),
|
|
1358
|
-
...(credentialFingerprint ? { credentialFingerprint } : {}),
|
|
1359
|
-
credentialKind: effectiveCredentialKind,
|
|
1360
|
-
status: "active",
|
|
1361
|
-
authState: accountAuthState,
|
|
1362
|
-
// An OAuth account pasted without a refresh token is a *static*
|
|
1363
|
-
// token: the pasted value is a bearer token used exactly as issued
|
|
1364
|
-
// and there is no refresh grant to run. Flag it as static so the
|
|
1365
|
-
// console reads it as an informational "static token" rather than
|
|
1366
|
-
// "re-login required" — the token is still valid and dispatchable —
|
|
1367
|
-
// and so the refresh sweep skips it instead of retrying a refresh
|
|
1368
|
-
// that can never succeed.
|
|
1369
|
-
...(effectiveCredentialKind === "oauth" && !oauthHasRefreshToken || effectiveCredentialMode === "jwt"
|
|
1370
|
-
? { staticToken: true }
|
|
1371
|
-
: {}),
|
|
1372
|
-
})
|
|
1373
|
-
.returning();
|
|
1374
|
-
const row = rows[0];
|
|
1375
|
-
if (!row) throw new Error("failed to create provider account");
|
|
1376
|
-
|
|
1377
|
-
if (effectiveCredentialKind === "oauth") {
|
|
1378
|
-
await tx.insert(providerOauthStates).values({
|
|
1379
|
-
providerAccountId: row.id,
|
|
1380
|
-
...(oauthRefreshCiphertext ? { refreshCiphertext: oauthRefreshCiphertext } : {}),
|
|
1381
|
-
expiresAt: oauthExpiresAt ?? null,
|
|
1382
|
-
});
|
|
1383
|
-
}
|
|
1384
|
-
if (request.credentialKind === "oauth" && !oauthHasRefreshToken) {
|
|
1385
|
-
pushStructuredConsoleLog(
|
|
1386
|
-
"info",
|
|
1387
|
-
"OAuth access token stored as a static API credential; it will not be refreshed",
|
|
1388
|
-
{
|
|
1389
|
-
event: "token_refresh",
|
|
1390
|
-
providerId,
|
|
1391
|
-
accountId: row.id,
|
|
1392
|
-
errorCode: "static_token",
|
|
1393
|
-
},
|
|
1394
|
-
);
|
|
1395
|
-
}
|
|
1396
|
-
|
|
1397
|
-
if (!includeUsage) {
|
|
1398
|
-
const emptyUsage = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
1399
|
-
return this.mapAccount(row, { today: emptyUsage, allTime: emptyUsage });
|
|
1400
|
-
}
|
|
1401
|
-
const [account] = await this.accountsWithUsage(tenantId, [row]);
|
|
1402
|
-
if (!account) throw new Error("created provider account could not be mapped");
|
|
1403
|
-
return account;
|
|
1404
|
-
};
|
|
1405
|
-
|
|
1406
|
-
try {
|
|
1407
|
-
if (existingTx !== undefined) {
|
|
1408
|
-
return await run(existingTx);
|
|
1409
|
-
}
|
|
1410
|
-
return await this.db.transaction(run);
|
|
1411
|
-
} catch (error) {
|
|
1412
|
-
if (isUniqueViolation(error)) {
|
|
1413
|
-
throw new ConsoleDomainError(
|
|
1414
|
-
"provider_account_duplicate",
|
|
1415
|
-
409,
|
|
1416
|
-
"A provider account with this credential already exists",
|
|
1417
|
-
);
|
|
1418
|
-
}
|
|
1419
|
-
throw error;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
/**
|
|
1424
|
-
* Creates many accounts in one round-trip.
|
|
1425
|
-
*
|
|
1426
|
-
* The whole batch shares one transaction, so a paste of 40 credentials costs
|
|
1427
|
-
* one connection checkout rather than 40. Each item is created through the
|
|
1428
|
-
* same `createAccount` path the single route uses — there is no second
|
|
1429
|
-
* implementation of credential detection to drift.
|
|
1430
|
-
*
|
|
1431
|
-
* Items are created sequentially inside that transaction: the unique
|
|
1432
|
-
* credential fingerprint makes two identical pastes collide, and a nested
|
|
1433
|
-
* transaction per item keeps that collision from aborting the whole batch.
|
|
1434
|
-
* Outcomes are reported per index, so a partial paste still lands the
|
|
1435
|
-
* accounts that were fine — an operator fixing one bad line should not have
|
|
1436
|
-
* to re-import 39.
|
|
1437
|
-
*/
|
|
1438
|
-
async createAccountBatch(
|
|
1439
|
-
tenantId: string,
|
|
1440
|
-
providerId: string,
|
|
1441
|
-
requests: readonly CreateProviderAccountRequest[],
|
|
1442
|
-
): Promise<CreateProviderAccountBatchResult> {
|
|
1443
|
-
if (requests.length === 0) {
|
|
1444
|
-
return { created: 0, failed: 0, results: [] };
|
|
1445
|
-
}
|
|
1446
|
-
const results: CreateProviderAccountBatchItemResult[] = [];
|
|
1447
|
-
let created = 0;
|
|
1448
|
-
let failed = 0;
|
|
1449
|
-
|
|
1450
|
-
await this.db.transaction(async (tx) => {
|
|
1451
|
-
let nextSortIndex = await this.nextAccountSortIndex(tx, providerId, tenantId);
|
|
1452
|
-
for (const [index, request] of requests.entries()) {
|
|
1453
|
-
try {
|
|
1454
|
-
// A nested transaction acts as a savepoint: a duplicate-fingerprint
|
|
1455
|
-
// violation rolls back only this item, not the accounts before it.
|
|
1456
|
-
const account = await tx.transaction(async (inner) =>
|
|
1457
|
-
this.createAccount(tenantId, providerId, request, inner, false, nextSortIndex),
|
|
1458
|
-
);
|
|
1459
|
-
created += 1;
|
|
1460
|
-
nextSortIndex += 1;
|
|
1461
|
-
results.push({ index, ok: true, ...(account ? { account } : {}) });
|
|
1462
|
-
} catch (error) {
|
|
1463
|
-
failed += 1;
|
|
1464
|
-
results.push({
|
|
1465
|
-
index,
|
|
1466
|
-
ok: false,
|
|
1467
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1468
|
-
code: error instanceof ConsoleDomainError ? error.code : "create_failed",
|
|
1469
|
-
});
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
});
|
|
1473
|
-
|
|
1474
|
-
return { created, failed, results };
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
async updateAccount(
|
|
1478
|
-
tenantId: string,
|
|
1479
|
-
providerId: string,
|
|
1480
|
-
accountId: string,
|
|
1481
|
-
patch: UpdateProviderAccountRequest,
|
|
1482
|
-
): Promise<ProviderAccountResponse | undefined> {
|
|
1483
|
-
const set: Partial<typeof providerAccounts.$inferInsert> = {};
|
|
1484
|
-
if (patch.label !== undefined) set.label = patch.label;
|
|
1485
|
-
if (patch.secret !== undefined) {
|
|
1486
|
-
set.credentialCiphertext = encryptCredential(patch.secret);
|
|
1487
|
-
set.credentialFingerprint =
|
|
1488
|
-
patch.secret.length === 0 ? null : hashSecret(patch.secret);
|
|
1489
|
-
}
|
|
1490
|
-
if (patch.status !== undefined) set.status = patch.status;
|
|
1491
|
-
// Toggling the static-token flag: turning it on declares the credential is
|
|
1492
|
-
// used as issued and never refreshed, so any stale re-auth flag is cleared —
|
|
1493
|
-
// the operator has said the account is fine, and the pill must stop reading
|
|
1494
|
-
// "Re-login required". Turning it off returns the account to normal OAuth
|
|
1495
|
-
// refresh handling; the sweep picks it up again on its next pass.
|
|
1496
|
-
if (patch.staticToken !== undefined) {
|
|
1497
|
-
set.staticToken = patch.staticToken;
|
|
1498
|
-
if (patch.staticToken) {
|
|
1499
|
-
set.lastError = null;
|
|
1500
|
-
set.lastErrorCategory = null;
|
|
1501
|
-
set.lastErrorAt = null;
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
// Status toggles are not recovery: preserve the health machine's evidence
|
|
1505
|
-
// until the operator uses Recover or replaces the rejected credential.
|
|
1506
|
-
const clearsFailureState = patch.secret !== undefined && patch.secret.length > 0;
|
|
1507
|
-
if (clearsFailureState) {
|
|
1508
|
-
set.consecutiveFailures = 0;
|
|
1509
|
-
set.cooldownUntil = null;
|
|
1510
|
-
set.modelCooldowns = {};
|
|
1511
|
-
set.lastError = null;
|
|
1512
|
-
set.lastErrorCategory = null;
|
|
1513
|
-
set.lastErrorAt = null;
|
|
1514
|
-
}
|
|
1515
|
-
const where = and(
|
|
1516
|
-
eq(providerAccounts.id, accountId),
|
|
1517
|
-
eq(providerAccounts.providerId, providerId),
|
|
1518
|
-
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1519
|
-
);
|
|
1520
|
-
try {
|
|
1521
|
-
const rows =
|
|
1522
|
-
Object.keys(set).length > 0
|
|
1523
|
-
? await this.db.update(providerAccounts).set(set).where(where).returning()
|
|
1524
|
-
: await this.db.select().from(providerAccounts).where(where);
|
|
1525
|
-
const row = rows[0];
|
|
1526
|
-
if (!row) return undefined;
|
|
1527
|
-
// Secret/status mutation: drop any cached credential so the next
|
|
1528
|
-
// attempt decrypts the new ciphertext (or refuses a disabled account).
|
|
1529
|
-
if (patch.secret !== undefined || patch.status !== undefined) {
|
|
1530
|
-
invalidateCredentialCache(accountId);
|
|
1531
|
-
}
|
|
1532
|
-
const [account] = await this.accountsWithUsage(tenantId, [row]);
|
|
1533
|
-
return account;
|
|
1534
|
-
} catch (error) {
|
|
1535
|
-
if (isUniqueViolation(error)) {
|
|
1536
|
-
throw new ConsoleDomainError(
|
|
1537
|
-
"provider_account_duplicate",
|
|
1538
|
-
409,
|
|
1539
|
-
"A provider account with this credential already exists",
|
|
1540
|
-
);
|
|
1541
|
-
}
|
|
1542
|
-
throw error;
|
|
1543
|
-
}
|
|
1544
|
-
}
|
|
1545
|
-
|
|
1546
|
-
private mapAccount(
|
|
1547
|
-
row: typeof providerAccounts.$inferSelect,
|
|
1548
|
-
usage: {
|
|
1549
|
-
readonly today: ProviderAccountTokenUsage;
|
|
1550
|
-
readonly allTime: ProviderAccountTokenUsage;
|
|
1551
|
-
readonly inflight?: number | undefined;
|
|
1552
|
-
},
|
|
1553
|
-
): ProviderAccountResponse {
|
|
1554
|
-
const authState =
|
|
1555
|
-
row.authState !== null && typeof row.authState === "object" && !Array.isArray(row.authState)
|
|
1556
|
-
? (row.authState as Record<string, unknown>)
|
|
1557
|
-
: undefined;
|
|
1558
|
-
const rawCredentialMode = authState?.["credentialMode"];
|
|
1559
|
-
const credentialMode: CredentialMode | undefined =
|
|
1560
|
-
rawCredentialMode === "auto" || rawCredentialMode === "jwt" || rawCredentialMode === "api_key"
|
|
1561
|
-
? rawCredentialMode
|
|
1562
|
-
: undefined;
|
|
1563
|
-
const tokenExpiresAt =
|
|
1564
|
-
typeof authState?.["tokenExpiresAt"] === "string" ? authState["tokenExpiresAt"] : undefined;
|
|
1565
|
-
const jwtIssuer =
|
|
1566
|
-
typeof authState?.["jwtIssuer"] === "string" ? authState["jwtIssuer"] : undefined;
|
|
1567
|
-
const authMethod =
|
|
1568
|
-
typeof authState?.["authMethod"] === "string" ? authState["authMethod"] : undefined;
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
...(
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
...(
|
|
1585
|
-
...(
|
|
1586
|
-
|
|
1587
|
-
...(
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
...(row.
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
)
|
|
1612
|
-
.
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
)
|
|
1633
|
-
.
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
)
|
|
1653
|
-
.
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
)
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
)
|
|
1690
|
-
.
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
)
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
)
|
|
1735
|
-
.
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
)
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
)
|
|
1773
|
-
.
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
*
|
|
1803
|
-
* the
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
const
|
|
1839
|
-
|
|
1840
|
-
.map((
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
const
|
|
1901
|
-
|
|
1902
|
-
providerId,
|
|
1903
|
-
|
|
1904
|
-
);
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
}
|
|
1
|
+
// Drizzle-backed provider, account, model, routing, and API-key repositories.
|
|
2
|
+
import { and, asc, eq, gte, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
|
|
3
|
+
import { liveModelCooldownExistsSql } from "../../../persistence/dialect";
|
|
4
|
+
import { ConsoleDomainError } from "../../shared/errors";
|
|
5
|
+
import { parseCustomProviderId, isBundledProviderId } from "../../../providers/provider-contracts";
|
|
6
|
+
import { providerRateLimitIsIpScoped } from "../../../providers/provider-metadata";
|
|
7
|
+
import type { ModelDefinition } from "../../../providers/provider-model-definition";
|
|
8
|
+
import type { ProviderRegistry } from "../../../providers/provider-registry";
|
|
9
|
+
import { BUNDLED_PROVIDER_MODULES } from "../../../providers/default-registry";
|
|
10
|
+
import { globalOrOwnedBy, ownedByOnly } from "../../../persistence/tenant-scope";
|
|
11
|
+
import { nextSortIndex, reorderSortIndex } from "../../../persistence/sort-order";
|
|
12
|
+
import type { CartethyiaDatabase } from "../../../persistence/db-handle";
|
|
13
|
+
import { buddyTaskLedger, models, networkPools, providerAccounts, providerOauthStates, providerPoolAccounts, providers, telemetryEvents, telemetryUsageTotals, tenantDisabledModels } from "../../../persistence/schema";
|
|
14
|
+
import { IS_GATEWAY_ERROR_SQL } from "../../../observability/event-query";
|
|
15
|
+
import type { WireFamily } from "../../../protocol/canonical-model";
|
|
16
|
+
import { listAccountHealthEvents, recoverAccount, deleteAccountHealthEvents, type AccountHealthEventRecord } from "../../../providers/operations/account-health-service";
|
|
17
|
+
import { invalidateCredentialCache, invalidateCredentialCaches } from "../../../providers/operations/provider-credential-service";
|
|
18
|
+
import { invalidateQuotaCaches } from "../../../providers/quota/cache";
|
|
19
|
+
import { encryptCredential, hashSecret } from "../../../security/crypto";
|
|
20
|
+
import type { TelemetryBatchBuffer } from "../../../observability/telemetry-buffer";
|
|
21
|
+
import type { RedisClient } from "../../../persistence/redis";
|
|
22
|
+
import { bumpCatalogRevision, type BundledProviderCatalog } from "../../../providers/operations/provider-catalog-service";
|
|
23
|
+
import type { AccountInflightReading, BuddyGrowthTaskLedgerEntry, ByokConnectionTestRequest, ByokConnectionTestResult, CreateProviderAccountBatchItemResult, CreateProviderAccountBatchResult, CreateProviderAccountRequest, CredentialMode, DiscoverModelCandidate, ModelCatalogEntry, ProbeAllAccountsResult, ProbeAllModelsResult, ProbeModelRequest, ProbeModelResult, ProviderAccountBulkActionResult, ProviderAccountResponse, ProviderAccountTokenUsage, ProviderCatalogStore, ProviderRecord, SetModelEnabledRequest, UpdateModelMetadataRequest, UpdateProviderAccountRequest, ModelMetadataOverride } from "./contracts";
|
|
24
|
+
import { isServiceKind, validateCompatibilityProfile } from "./contracts";
|
|
25
|
+
import { ProviderProbingService, type ProbeOutboundResolver } from "../../../providers/discovery/probing-service";
|
|
26
|
+
import {
|
|
27
|
+
applyModelMetadataOverride,
|
|
28
|
+
resolveManualModelMetadata,
|
|
29
|
+
} from "../../../providers/provider-model-definition";
|
|
30
|
+
import { isUniqueViolation } from "../../../persistence/boot";
|
|
31
|
+
import { DEFAULT_ENDPOINT_BY_WIRE_FAMILY, endpointPathForProviderModel, mapProviderRow } from "./catalog-projections";
|
|
32
|
+
import { pushStructuredConsoleLog } from "../../../observability/log-ring";
|
|
33
|
+
|
|
34
|
+
const emptyUsage: ProviderAccountTokenUsage = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The owning pool's provider cooldown markers, filtered to the ones still in
|
|
38
|
+
* force and shaped like `NetworkPoolResponse.providerCooldowns`. Expired
|
|
39
|
+
* markers are skipped — a spent window is not a reason to flag the row.
|
|
40
|
+
*/
|
|
41
|
+
function activePoolProviderCooldowns(
|
|
42
|
+
markers: Record<string, { until: number; reason: string; blocked?: boolean }>,
|
|
43
|
+
): { providerId: string; until: string; reason: string; blocked: boolean }[] {
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
const active: { providerId: string; until: string; reason: string; blocked: boolean }[] = [];
|
|
46
|
+
for (const [providerId, marker] of Object.entries(markers)) {
|
|
47
|
+
if (marker.until <= now) continue;
|
|
48
|
+
active.push({
|
|
49
|
+
providerId,
|
|
50
|
+
until: new Date(marker.until).toISOString(),
|
|
51
|
+
reason: marker.reason,
|
|
52
|
+
blocked: marker.blocked === true,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return active;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Real Drizzle-backed provider and model catalog repository. */
|
|
59
|
+
|
|
60
|
+
import {
|
|
61
|
+
asStoredText,
|
|
62
|
+
liveModelCooldowns,
|
|
63
|
+
mapModelRow,
|
|
64
|
+
nextGeneratedAccountLabel,
|
|
65
|
+
parseJwtCredential,
|
|
66
|
+
parsePastedOAuthCredential,
|
|
67
|
+
withImageModality,
|
|
68
|
+
} from "./store-helpers";
|
|
69
|
+
|
|
70
|
+
export {
|
|
71
|
+
accountLabelKindSegment,
|
|
72
|
+
mapModelRow,
|
|
73
|
+
nextGeneratedAccountLabel,
|
|
74
|
+
withImageModality,
|
|
75
|
+
} from "./store-helpers";
|
|
76
|
+
|
|
77
|
+
export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
|
|
78
|
+
private readonly probing: ProviderProbingService;
|
|
79
|
+
private readonly bundledModelCatalog: ReadonlyMap<string, readonly ModelDefinition[]>;
|
|
80
|
+
private readonly providerRegistry: ProviderRegistry;
|
|
81
|
+
private readonly redis: RedisClient | undefined;
|
|
82
|
+
|
|
83
|
+
private readonly readAccountInflight:
|
|
84
|
+
| ((
|
|
85
|
+
providerId: string,
|
|
86
|
+
tenantId: string,
|
|
87
|
+
) => Promise<readonly AccountInflightReading[]>)
|
|
88
|
+
| undefined;
|
|
89
|
+
|
|
90
|
+
constructor(
|
|
91
|
+
private readonly db: CartethyiaDatabase,
|
|
92
|
+
options: {
|
|
93
|
+
readonly telemetryBuffer: TelemetryBatchBuffer;
|
|
94
|
+
readonly bundledModelCatalog: BundledProviderCatalog;
|
|
95
|
+
readonly providerRegistry: ProviderRegistry;
|
|
96
|
+
readonly outboundFetchFor: ProbeOutboundResolver;
|
|
97
|
+
readonly snapshotInvalidator: { invalidate(): unknown };
|
|
98
|
+
readonly readAccountInflight?:
|
|
99
|
+
| ((
|
|
100
|
+
providerId: string,
|
|
101
|
+
tenantId: string,
|
|
102
|
+
) => Promise<readonly AccountInflightReading[]>)
|
|
103
|
+
| undefined;
|
|
104
|
+
/** Shared Redis handle for quota-cache invalidation on bulk delete. */
|
|
105
|
+
readonly redis?: RedisClient | undefined;
|
|
106
|
+
},
|
|
107
|
+
) {
|
|
108
|
+
this.bundledModelCatalog = options.bundledModelCatalog.modelsByProvider;
|
|
109
|
+
this.providerRegistry = options.providerRegistry;
|
|
110
|
+
this.readAccountInflight = options.readAccountInflight;
|
|
111
|
+
this.redis = options.redis;
|
|
112
|
+
this.probing = new ProviderProbingService({
|
|
113
|
+
db,
|
|
114
|
+
telemetryBuffer: options.telemetryBuffer,
|
|
115
|
+
defaultEndpoints: DEFAULT_ENDPOINT_BY_WIRE_FAMILY,
|
|
116
|
+
bundledModelCatalog: this.bundledModelCatalog,
|
|
117
|
+
outboundFetchFor: options.outboundFetchFor,
|
|
118
|
+
snapshotInvalidator: options.snapshotInvalidator,
|
|
119
|
+
providerRegistry: options.providerRegistry,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async list(tenantId: string): Promise<readonly ProviderRecord[]> {
|
|
123
|
+
const bundledIds = BUNDLED_PROVIDER_MODULES.map((provider) => provider.id);
|
|
124
|
+
const rows = await this.db
|
|
125
|
+
.select()
|
|
126
|
+
.from(providers)
|
|
127
|
+
.where(
|
|
128
|
+
and(
|
|
129
|
+
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
130
|
+
or(inArray(providers.id, bundledIds), eq(providers.tenantId, tenantId)),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
133
|
+
.orderBy(asc(providers.id));
|
|
134
|
+
return rows.map((row) => mapProviderRow(row, this.providerRegistry));
|
|
135
|
+
}
|
|
136
|
+
async get(tenantId: string, providerId: string): Promise<ProviderRecord | undefined> {
|
|
137
|
+
const rows = await this.db
|
|
138
|
+
.select()
|
|
139
|
+
.from(providers)
|
|
140
|
+
.where(
|
|
141
|
+
and(
|
|
142
|
+
eq(providers.id, providerId),
|
|
143
|
+
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
.limit(1);
|
|
147
|
+
const row = rows[0];
|
|
148
|
+
return row ? mapProviderRow(row, this.providerRegistry) : undefined;
|
|
149
|
+
}
|
|
150
|
+
async create(record: ProviderRecord): Promise<void> {
|
|
151
|
+
if (isBundledProviderId(record.providerId))
|
|
152
|
+
throw new ConsoleDomainError(
|
|
153
|
+
"provider_is_builtin",
|
|
154
|
+
403,
|
|
155
|
+
"Built-in provider cannot be created",
|
|
156
|
+
);
|
|
157
|
+
parseCustomProviderId(record.providerId);
|
|
158
|
+
if (record.compatibilityProfile) validateCompatibilityProfile(record.compatibilityProfile);
|
|
159
|
+
await this.db.insert(providers).values({
|
|
160
|
+
id: record.providerId,
|
|
161
|
+
tenantId: record.tenantId ?? null,
|
|
162
|
+
enabled: record.enabled,
|
|
163
|
+
...(record.wireFamilyDefault ? { wireFamilyDefault: record.wireFamilyDefault } : {}),
|
|
164
|
+
capabilityProfile: record.capabilityProfile ?? null,
|
|
165
|
+
baseUrl: record.baseUrl ?? null,
|
|
166
|
+
compatibilityProfile: record.compatibilityProfile ?? null,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async update(
|
|
170
|
+
tenantId: string,
|
|
171
|
+
providerId: string,
|
|
172
|
+
patch: Partial<ProviderRecord>,
|
|
173
|
+
): Promise<ProviderRecord | undefined> {
|
|
174
|
+
if (isBundledProviderId(providerId))
|
|
175
|
+
throw new ConsoleDomainError(
|
|
176
|
+
"provider_is_builtin",
|
|
177
|
+
403,
|
|
178
|
+
"Built-in provider cannot be updated",
|
|
179
|
+
);
|
|
180
|
+
if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
|
|
181
|
+
const set: Partial<typeof providers.$inferInsert> = {};
|
|
182
|
+
if (patch.enabled !== undefined) set.enabled = patch.enabled;
|
|
183
|
+
if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
|
|
184
|
+
if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
|
|
185
|
+
if (patch.compatibilityProfile !== undefined)
|
|
186
|
+
set.compatibilityProfile = patch.compatibilityProfile;
|
|
187
|
+
const rows =
|
|
188
|
+
Object.keys(set).length > 0
|
|
189
|
+
? await this.db
|
|
190
|
+
.update(providers)
|
|
191
|
+
.set(set)
|
|
192
|
+
.where(
|
|
193
|
+
and(
|
|
194
|
+
eq(providers.id, providerId),
|
|
195
|
+
ownedByOnly(providers.tenantId, tenantId),
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
.returning()
|
|
199
|
+
: await this.db
|
|
200
|
+
.select()
|
|
201
|
+
.from(providers)
|
|
202
|
+
.where(
|
|
203
|
+
and(
|
|
204
|
+
eq(providers.id, providerId),
|
|
205
|
+
ownedByOnly(providers.tenantId, tenantId),
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
const row = rows[0];
|
|
209
|
+
return row ? mapProviderRow(row, this.providerRegistry) : undefined;
|
|
210
|
+
}
|
|
211
|
+
async delete(tenantId: string, providerId: string): Promise<boolean> {
|
|
212
|
+
if (isBundledProviderId(providerId))
|
|
213
|
+
throw new ConsoleDomainError(
|
|
214
|
+
"provider_is_builtin",
|
|
215
|
+
403,
|
|
216
|
+
"Built-in provider cannot be deleted",
|
|
217
|
+
);
|
|
218
|
+
// Custom-provider rows own their models, accounts, disabled-model flags,
|
|
219
|
+
// and routing preferences: deleting the parent cascades all of them
|
|
220
|
+
// (models/provider_accounts/provider_oauth_states via provider FK,
|
|
221
|
+
// health_events via account FK, tenant_disabled_models and
|
|
222
|
+
// provider_routing_settings via provider FK) in one atomic DELETE.
|
|
223
|
+
const deleted = await this.db
|
|
224
|
+
.delete(providers)
|
|
225
|
+
.where(and(eq(providers.id, providerId), ownedByOnly(providers.tenantId, tenantId)))
|
|
226
|
+
.returning({ id: providers.id });
|
|
227
|
+
if (deleted.length === 0) return false;
|
|
228
|
+
this.providerRegistry.unregister(providerId);
|
|
229
|
+
// The provider left the registry, so the compiled catalog is now missing it
|
|
230
|
+
// — the snapshot's re-seed is gated on that revision, and a delete that did
|
|
231
|
+
// not bump it would leave the provider's model rows behind.
|
|
232
|
+
bumpCatalogRevision();
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
async updateGlobal(
|
|
236
|
+
providerId: string,
|
|
237
|
+
patch: Partial<ProviderRecord>,
|
|
238
|
+
): Promise<ProviderRecord | undefined> {
|
|
239
|
+
const isBuiltin = isBundledProviderId(providerId);
|
|
240
|
+
if (isBuiltin && Object.keys(patch).some((field) => field !== "enabled")) {
|
|
241
|
+
throw new ConsoleDomainError(
|
|
242
|
+
"provider_is_builtin",
|
|
243
|
+
403,
|
|
244
|
+
"Built-in providers only support enable/disable",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (patch.compatibilityProfile) validateCompatibilityProfile(patch.compatibilityProfile);
|
|
248
|
+
const set: Partial<typeof providers.$inferInsert> = {};
|
|
249
|
+
if (patch.enabled !== undefined) set.enabled = patch.enabled;
|
|
250
|
+
if (patch.capabilityProfile !== undefined) set.capabilityProfile = patch.capabilityProfile;
|
|
251
|
+
if (patch.baseUrl !== undefined) set.baseUrl = patch.baseUrl;
|
|
252
|
+
if (patch.compatibilityProfile !== undefined) {
|
|
253
|
+
set.compatibilityProfile = patch.compatibilityProfile;
|
|
254
|
+
}
|
|
255
|
+
const rows =
|
|
256
|
+
Object.keys(set).length > 0
|
|
257
|
+
? await this.db
|
|
258
|
+
.update(providers)
|
|
259
|
+
.set(set)
|
|
260
|
+
.where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
|
|
261
|
+
.returning()
|
|
262
|
+
: await this.db
|
|
263
|
+
.select()
|
|
264
|
+
.from(providers)
|
|
265
|
+
.where(and(eq(providers.id, providerId), isNull(providers.tenantId)));
|
|
266
|
+
const row = rows[0];
|
|
267
|
+
return row ? mapProviderRow(row, this.providerRegistry) : undefined;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async deleteGlobal(providerId: string): Promise<boolean> {
|
|
271
|
+
if (isBundledProviderId(providerId)) {
|
|
272
|
+
throw new ConsoleDomainError(
|
|
273
|
+
"provider_is_builtin",
|
|
274
|
+
403,
|
|
275
|
+
"Built-in provider cannot be deleted",
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
const rows = await this.db
|
|
279
|
+
.delete(providers)
|
|
280
|
+
.where(and(eq(providers.id, providerId), isNull(providers.tenantId)))
|
|
281
|
+
.returning({ id: providers.id });
|
|
282
|
+
return rows.length > 0;
|
|
283
|
+
}
|
|
284
|
+
async listModels(tenantId: string, providerId: string): Promise<readonly ModelCatalogEntry[]> {
|
|
285
|
+
const byProvider = await this.listModelsForTenant(tenantId, providerId);
|
|
286
|
+
return byProvider.get(providerId) ?? [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Every enabled-and-disabled model row the tenant can see, grouped by
|
|
291
|
+
* provider. One pair of statements for the whole tenant instead of a pair per
|
|
292
|
+
* provider.
|
|
293
|
+
*
|
|
294
|
+
* The per-provider `listModels` shape costs two queries per call, so a caller
|
|
295
|
+
* that needs every provider's models — the flat catalog the picker renders —
|
|
296
|
+
* issued `2 * providers + 2` statements, growing with the bundled catalog.
|
|
297
|
+
* Grouping here makes it constant: one disabled-model read and one models
|
|
298
|
+
* join, regardless of provider count. `providerId` narrows it to a single
|
|
299
|
+
* provider for callers that already know which one they want.
|
|
300
|
+
*/
|
|
301
|
+
async listModelsForTenant(
|
|
302
|
+
tenantId: string,
|
|
303
|
+
providerId?: string,
|
|
304
|
+
): Promise<Map<string, readonly ModelCatalogEntry[]>> {
|
|
305
|
+
const disabled = await this.db
|
|
306
|
+
.select({
|
|
307
|
+
providerId: tenantDisabledModels.providerId,
|
|
308
|
+
modelId: tenantDisabledModels.modelId,
|
|
309
|
+
endpointPath: tenantDisabledModels.endpointPath,
|
|
310
|
+
})
|
|
311
|
+
.from(tenantDisabledModels)
|
|
312
|
+
.where(
|
|
313
|
+
providerId === undefined
|
|
314
|
+
? eq(tenantDisabledModels.tenantId, tenantId)
|
|
315
|
+
: and(
|
|
316
|
+
eq(tenantDisabledModels.tenantId, tenantId),
|
|
317
|
+
eq(tenantDisabledModels.providerId, providerId),
|
|
318
|
+
),
|
|
319
|
+
);
|
|
320
|
+
const disabledKeys = new Set(
|
|
321
|
+
disabled.map((d) => `${d.providerId}::${d.modelId}::${d.endpointPath}`),
|
|
322
|
+
);
|
|
323
|
+
const dbRows = await this.db
|
|
324
|
+
.select({ model: models })
|
|
325
|
+
.from(models)
|
|
326
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
327
|
+
.where(
|
|
328
|
+
and(
|
|
329
|
+
...(providerId === undefined ? [] : [eq(models.providerId, providerId)]),
|
|
330
|
+
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
331
|
+
),
|
|
332
|
+
);
|
|
333
|
+
const grouped = new Map<string, ModelCatalogEntry[]>();
|
|
334
|
+
for (const { model: row } of dbRows) {
|
|
335
|
+
const isDisabled = disabledKeys.has(
|
|
336
|
+
`${row.providerId}::${row.modelId}::${row.endpointPath}`,
|
|
337
|
+
);
|
|
338
|
+
const list = grouped.get(row.providerId) ?? [];
|
|
339
|
+
list.push(mapModelRow(row, isDisabled));
|
|
340
|
+
grouped.set(row.providerId, list);
|
|
341
|
+
}
|
|
342
|
+
return grouped;
|
|
343
|
+
}
|
|
344
|
+
async registerModels(
|
|
345
|
+
tenantId: string,
|
|
346
|
+
providerId: string,
|
|
347
|
+
modelIds: readonly string[],
|
|
348
|
+
wireFamily?: string,
|
|
349
|
+
metadata?: ModelMetadataOverride,
|
|
350
|
+
): Promise<void> {
|
|
351
|
+
const owner = await this.db
|
|
352
|
+
.select({
|
|
353
|
+
id: providers.id,
|
|
354
|
+
baseUrl: providers.baseUrl,
|
|
355
|
+
compatibilityProfile: providers.compatibilityProfile,
|
|
356
|
+
})
|
|
357
|
+
.from(providers)
|
|
358
|
+
.where(and(eq(providers.id, providerId), globalOrOwnedBy(providers.tenantId, tenantId)))
|
|
359
|
+
.limit(1);
|
|
360
|
+
if (!owner[0]) {
|
|
361
|
+
throw new ConsoleDomainError("provider_not_found", 404, `Provider ${providerId} not found`);
|
|
362
|
+
}
|
|
363
|
+
const family = (wireFamily ?? "chat") as WireFamily;
|
|
364
|
+
// Static endpoint from the provider's own bundled catalog via the probing
|
|
365
|
+
// service (the same startup-built map this store is constructed with).
|
|
366
|
+
// Without this, bundled providers on versioned bases (cline) get the
|
|
367
|
+
// generic `/v1/…` default, which joins into an unreachable path and every
|
|
368
|
+
// probe/dispatch 404s.
|
|
369
|
+
const staticEndpoint = await this.probing.resolveStaticEndpoint(providerId, family);
|
|
370
|
+
const endpointPath = endpointPathForProviderModel(
|
|
371
|
+
family,
|
|
372
|
+
owner[0].compatibilityProfile,
|
|
373
|
+
staticEndpoint === undefined ? undefined : { [family]: staticEndpoint },
|
|
374
|
+
);
|
|
375
|
+
if (modelIds.length === 0) return;
|
|
376
|
+
// Single multi-row insert and upsert of the capability flags so a
|
|
377
|
+
// re-registration repairs a row whose `tool_call` (and `reasoning`) was
|
|
378
|
+
// seeded false — a manual add persists schema defaults, which capability
|
|
379
|
+
// preflight reads as "route cannot use tools" and silently strips `tools`
|
|
380
|
+
// from every request. `enabled` stays operator-owned; `source` is refreshed
|
|
381
|
+
// to `manual`.
|
|
382
|
+
await this.db
|
|
383
|
+
.insert(models)
|
|
384
|
+
.values(
|
|
385
|
+
modelIds.map((modelId) => ({
|
|
386
|
+
providerId,
|
|
387
|
+
modelId,
|
|
388
|
+
wireFamily: family,
|
|
389
|
+
endpointPath,
|
|
390
|
+
...applyModelMetadataOverride(
|
|
391
|
+
resolveManualModelMetadata(providerId, modelId),
|
|
392
|
+
metadata,
|
|
393
|
+
),
|
|
394
|
+
source: "manual",
|
|
395
|
+
sourceUpdatedAt: new Date(),
|
|
396
|
+
enabled: true,
|
|
397
|
+
})),
|
|
398
|
+
)
|
|
399
|
+
.onConflictDoUpdate({
|
|
400
|
+
target: [models.providerId, models.modelId, models.endpointPath],
|
|
401
|
+
set: {
|
|
402
|
+
wireFamily: sql`excluded.wire_family`,
|
|
403
|
+
contextLimit: sql`excluded.context_limit`,
|
|
404
|
+
outputLimit: sql`excluded.output_limit`,
|
|
405
|
+
modalities: sql`excluded.modalities`,
|
|
406
|
+
reasoning: sql`excluded.reasoning`,
|
|
407
|
+
toolCall: sql`excluded.tool_call`,
|
|
408
|
+
source: sql`'manual'`,
|
|
409
|
+
sourceUpdatedAt: sql`excluded.source_updated_at`,
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* One-shot connectivity probe and model discovery live in
|
|
416
|
+
* `services/provider-probing.ts` — the store only forwards so the domain
|
|
417
|
+
* `ProviderCatalogStore` interface is unchanged.
|
|
418
|
+
*/
|
|
419
|
+
async probeModel(
|
|
420
|
+
tenantId: string,
|
|
421
|
+
providerId: string,
|
|
422
|
+
request: ProbeModelRequest,
|
|
423
|
+
): Promise<ProbeModelResult> {
|
|
424
|
+
return this.probing.probeModel(tenantId, providerId, request);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Ad-hoc BYOK connectivity test; delegates to the shared probing service. */
|
|
428
|
+
async testByokConnection(
|
|
429
|
+
tenantId: string,
|
|
430
|
+
request: ByokConnectionTestRequest,
|
|
431
|
+
): Promise<ByokConnectionTestResult> {
|
|
432
|
+
return this.probing.testByokConnection(tenantId, request);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Batched provider-wide probe: the first registered model runs sequentially
|
|
437
|
+
* (warms OAuth token / connection pools), the rest run concurrently bounded
|
|
438
|
+
* to 5. Registered model ids come from this store's own catalog read.
|
|
439
|
+
*/
|
|
440
|
+
async probeAllModels(
|
|
441
|
+
tenantId: string,
|
|
442
|
+
providerId: string,
|
|
443
|
+
mode?: ProbeModelRequest["mode"],
|
|
444
|
+
): Promise<ProbeAllModelsResult> {
|
|
445
|
+
const entries = await this.listModels(tenantId, providerId);
|
|
446
|
+
const requests = entries.map((entry) => ({
|
|
447
|
+
modelId: entry.modelId,
|
|
448
|
+
route: entry.route,
|
|
449
|
+
serviceKind: isServiceKind(entry.serviceKind) ? entry.serviceKind : ("llm" as const),
|
|
450
|
+
...(mode === undefined ? {} : { mode }),
|
|
451
|
+
}));
|
|
452
|
+
const uniqueRequests = requests.filter(
|
|
453
|
+
(request, index, all) =>
|
|
454
|
+
all.findIndex(
|
|
455
|
+
(candidate) =>
|
|
456
|
+
candidate.modelId === request.modelId &&
|
|
457
|
+
candidate.route === request.route &&
|
|
458
|
+
candidate.serviceKind === request.serviceKind,
|
|
459
|
+
) === index,
|
|
460
|
+
);
|
|
461
|
+
return this.probing.probeAllModels(tenantId, providerId, uniqueRequests);
|
|
462
|
+
}
|
|
463
|
+
async probeAllAccounts(
|
|
464
|
+
tenantId: string,
|
|
465
|
+
providerId: string,
|
|
466
|
+
request: ProbeModelRequest,
|
|
467
|
+
): Promise<ProbeAllAccountsResult> {
|
|
468
|
+
return this.probing.probeAllAccounts(tenantId, providerId, request);
|
|
469
|
+
}
|
|
470
|
+
/** Toggles a persisted model's routing eligibility. */
|
|
471
|
+
async setModelEnabled(
|
|
472
|
+
tenantId: string,
|
|
473
|
+
providerId: string,
|
|
474
|
+
request: SetModelEnabledRequest,
|
|
475
|
+
): Promise<boolean> {
|
|
476
|
+
// Tenant-owned (BYOK) model: toggle the shared row directly.
|
|
477
|
+
const owned = await this.db
|
|
478
|
+
.select({ id: models.id })
|
|
479
|
+
.from(models)
|
|
480
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
481
|
+
.where(
|
|
482
|
+
and(
|
|
483
|
+
eq(models.providerId, providerId),
|
|
484
|
+
eq(models.modelId, request.modelId),
|
|
485
|
+
eq(models.endpointPath, request.route),
|
|
486
|
+
ownedByOnly(providers.tenantId, tenantId),
|
|
487
|
+
),
|
|
488
|
+
)
|
|
489
|
+
.limit(1);
|
|
490
|
+
const ownedTarget = owned[0];
|
|
491
|
+
if (ownedTarget) {
|
|
492
|
+
await this.db
|
|
493
|
+
.update(models)
|
|
494
|
+
.set({ enabled: request.enabled })
|
|
495
|
+
.where(eq(models.id, ownedTarget.id));
|
|
496
|
+
return true;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Global/built-in model: tenants cannot mutate the shared row, so record
|
|
500
|
+
// (or clear) a tenant-scoped disable in `tenantDisabledModels`.
|
|
501
|
+
const global = await this.db
|
|
502
|
+
.select({ id: models.id })
|
|
503
|
+
.from(models)
|
|
504
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
505
|
+
.where(
|
|
506
|
+
and(
|
|
507
|
+
eq(models.providerId, providerId),
|
|
508
|
+
eq(models.modelId, request.modelId),
|
|
509
|
+
eq(models.endpointPath, request.route),
|
|
510
|
+
isNull(providers.tenantId),
|
|
511
|
+
),
|
|
512
|
+
)
|
|
513
|
+
.limit(1);
|
|
514
|
+
if (global[0] === undefined) return false;
|
|
515
|
+
if (request.enabled) {
|
|
516
|
+
await this.db
|
|
517
|
+
.delete(tenantDisabledModels)
|
|
518
|
+
.where(
|
|
519
|
+
and(
|
|
520
|
+
eq(tenantDisabledModels.tenantId, tenantId),
|
|
521
|
+
eq(tenantDisabledModels.providerId, providerId),
|
|
522
|
+
eq(tenantDisabledModels.modelId, request.modelId),
|
|
523
|
+
eq(tenantDisabledModels.endpointPath, request.route),
|
|
524
|
+
),
|
|
525
|
+
);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
await this.db
|
|
529
|
+
.insert(tenantDisabledModels)
|
|
530
|
+
.values({
|
|
531
|
+
tenantId,
|
|
532
|
+
providerId,
|
|
533
|
+
modelId: request.modelId,
|
|
534
|
+
endpointPath: request.route,
|
|
535
|
+
})
|
|
536
|
+
.onConflictDoNothing({
|
|
537
|
+
target: [
|
|
538
|
+
tenantDisabledModels.tenantId,
|
|
539
|
+
tenantDisabledModels.providerId,
|
|
540
|
+
tenantDisabledModels.modelId,
|
|
541
|
+
tenantDisabledModels.endpointPath,
|
|
542
|
+
],
|
|
543
|
+
});
|
|
544
|
+
return true;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Updates one model's limits and capability flags.
|
|
549
|
+
*
|
|
550
|
+
* Two row kinds are writable. A tenant-owned (BYOK) row is updated directly.
|
|
551
|
+
* A model belonging to a *global* provider row is also updated directly:
|
|
552
|
+
* bundled providers are seeded once with `tenant_id IS NULL` and `providers.id`
|
|
553
|
+
* is the primary key, so a bundled provider can only ever have that one row —
|
|
554
|
+
* there is no tenant-scoped row to edit instead. Auto-fetched rows
|
|
555
|
+
* (`discovered` / `auto_free`) live on exactly those providers, which is why
|
|
556
|
+
* the previous `ownedByOnly` filter answered `not_found` for every model the
|
|
557
|
+
* operator had just fetched and could not be edited at all.
|
|
558
|
+
*
|
|
559
|
+
* A global `builtin` row is rejected: it is re-seeded from the bundle on every
|
|
560
|
+
* boot, so an edit there would be silently reverted, and `deleteModel` refuses
|
|
561
|
+
* it for the same reason.
|
|
562
|
+
*
|
|
563
|
+
* Absent override fields leave the stored value untouched — an edit that only
|
|
564
|
+
* sets the context ceiling must not reset the capability flags.
|
|
565
|
+
*/
|
|
566
|
+
async updateModelMetadata(
|
|
567
|
+
tenantId: string,
|
|
568
|
+
providerId: string,
|
|
569
|
+
request: UpdateModelMetadataRequest,
|
|
570
|
+
): Promise<boolean> {
|
|
571
|
+
const findRow = (ownership: SQL) =>
|
|
572
|
+
this.db
|
|
573
|
+
.select({ id: models.id, modalities: models.modalities, source: models.source })
|
|
574
|
+
// source is read for both the global builtin guard above and the new
|
|
575
|
+
// non-builtin-only warning edit.
|
|
576
|
+
.from(models)
|
|
577
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
578
|
+
.where(
|
|
579
|
+
and(
|
|
580
|
+
eq(models.providerId, providerId),
|
|
581
|
+
eq(models.modelId, request.modelId),
|
|
582
|
+
eq(models.endpointPath, request.route),
|
|
583
|
+
ownership,
|
|
584
|
+
),
|
|
585
|
+
)
|
|
586
|
+
.limit(1);
|
|
587
|
+
const owned = await findRow(ownedByOnly(providers.tenantId, tenantId));
|
|
588
|
+
let row = owned[0];
|
|
589
|
+
if (row === undefined) {
|
|
590
|
+
const global = await findRow(isNull(providers.tenantId));
|
|
591
|
+
const candidate = global[0];
|
|
592
|
+
if (candidate === undefined) return false;
|
|
593
|
+
if (candidate.source === "builtin") {
|
|
594
|
+
throw new ConsoleDomainError(
|
|
595
|
+
"builtin_model_immutable",
|
|
596
|
+
403,
|
|
597
|
+
`Built-in model ${providerId}/${request.modelId} cannot be edited — the bundle re-seeds it on every boot`,
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
row = candidate;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const override = request.metadata;
|
|
604
|
+
const set: Record<string, unknown> = { sourceUpdatedAt: new Date() };
|
|
605
|
+
if (override?.contextLimit !== undefined) set.contextLimit = override.contextLimit;
|
|
606
|
+
if (override?.outputLimit !== undefined) set.outputLimit = override.outputLimit;
|
|
607
|
+
// Passthrough grants every capability and cannot be partly revoked by a
|
|
608
|
+
// per-flag field: the point of the flag is that the operator does not know
|
|
609
|
+
// which capabilities the model has, so an explicit `false` alongside it is
|
|
610
|
+
// either stale or a mistake.
|
|
611
|
+
// Builtin rows are re-seeded from the static catalog on every boot, so a
|
|
612
|
+
// warning edit there would be reverted. Non-builtin rows (including ones a
|
|
613
|
+
// discovery commit created) are operator-owned and may set or clear them.
|
|
614
|
+
if (override?.warnings !== undefined) {
|
|
615
|
+
if (row.source === "builtin") {
|
|
616
|
+
throw new ConsoleDomainError(
|
|
617
|
+
"builtin_model_immutable",
|
|
618
|
+
403,
|
|
619
|
+
`Built-in model ${providerId}/${request.modelId} warnings cannot be edited — the bundle re-seeds them on every boot`,
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
set.warningMetadata =
|
|
623
|
+
override.warnings === null
|
|
624
|
+
? null
|
|
625
|
+
: {
|
|
626
|
+
warnings: override.warnings.map((warning) => ({
|
|
627
|
+
kind: warning.kind,
|
|
628
|
+
message: warning.message,
|
|
629
|
+
...(warning.severity === undefined ? {} : { severity: warning.severity }),
|
|
630
|
+
...(warning.updatedAt === undefined ? {} : { updatedAt: warning.updatedAt }),
|
|
631
|
+
})),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
if (override?.passthrough === true) {
|
|
635
|
+
set.reasoning = true;
|
|
636
|
+
set.toolCall = true;
|
|
637
|
+
set.modalities = withImageModality(asStoredText(row.modalities), true);
|
|
638
|
+
} else {
|
|
639
|
+
if (override?.reasoning !== undefined) set.reasoning = override.reasoning;
|
|
640
|
+
if (override?.toolCall !== undefined) set.toolCall = override.toolCall;
|
|
641
|
+
if (override?.image !== undefined)
|
|
642
|
+
set.modalities = withImageModality(asStoredText(row.modalities), override.image);
|
|
643
|
+
}
|
|
644
|
+
await this.db.update(models).set(set).where(eq(models.id, row.id));
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async deleteModel(
|
|
649
|
+
tenantId: string,
|
|
650
|
+
providerId: string,
|
|
651
|
+
request: SetModelEnabledRequest,
|
|
652
|
+
): Promise<boolean> {
|
|
653
|
+
const owned = await this.db
|
|
654
|
+
.select({ id: models.id })
|
|
655
|
+
.from(models)
|
|
656
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
657
|
+
.where(
|
|
658
|
+
and(
|
|
659
|
+
eq(models.providerId, providerId),
|
|
660
|
+
eq(models.modelId, request.modelId),
|
|
661
|
+
eq(models.endpointPath, request.route),
|
|
662
|
+
ownedByOnly(providers.tenantId, tenantId),
|
|
663
|
+
),
|
|
664
|
+
)
|
|
665
|
+
.limit(1);
|
|
666
|
+
const target = owned[0];
|
|
667
|
+
if (target) {
|
|
668
|
+
const rows = await this.db
|
|
669
|
+
.delete(models)
|
|
670
|
+
.where(eq(models.id, target.id))
|
|
671
|
+
.returning({ id: models.id });
|
|
672
|
+
return rows.length > 0;
|
|
673
|
+
}
|
|
674
|
+
const global = await this.db
|
|
675
|
+
.select({ id: models.id, source: models.source })
|
|
676
|
+
.from(models)
|
|
677
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
678
|
+
.where(
|
|
679
|
+
and(
|
|
680
|
+
eq(models.providerId, providerId),
|
|
681
|
+
eq(models.modelId, request.modelId),
|
|
682
|
+
eq(models.endpointPath, request.route),
|
|
683
|
+
isNull(providers.tenantId),
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
.limit(1);
|
|
687
|
+
if (!global[0]) return false;
|
|
688
|
+
// Built-in models are immutable catalog: tenants can only disable them
|
|
689
|
+
// (via tenantDisabledModels), never delete the shared row.
|
|
690
|
+
if (global[0].source === "builtin") {
|
|
691
|
+
throw new ConsoleDomainError(
|
|
692
|
+
"builtin_model_immutable",
|
|
693
|
+
403,
|
|
694
|
+
`Built-in model ${providerId}/${request.modelId} cannot be deleted — disable it instead`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
const rows = await this.db
|
|
698
|
+
.delete(models)
|
|
699
|
+
.where(eq(models.id, global[0].id))
|
|
700
|
+
.returning({ id: models.id });
|
|
701
|
+
await this.db
|
|
702
|
+
.delete(tenantDisabledModels)
|
|
703
|
+
.where(
|
|
704
|
+
and(
|
|
705
|
+
eq(tenantDisabledModels.tenantId, tenantId),
|
|
706
|
+
eq(tenantDisabledModels.providerId, providerId),
|
|
707
|
+
eq(tenantDisabledModels.modelId, request.modelId),
|
|
708
|
+
eq(tenantDisabledModels.endpointPath, request.route),
|
|
709
|
+
),
|
|
710
|
+
);
|
|
711
|
+
return rows.length > 0;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private async accountsWithUsage(
|
|
715
|
+
tenantId: string,
|
|
716
|
+
rows: readonly (typeof providerAccounts.$inferSelect)[],
|
|
717
|
+
): Promise<readonly ProviderAccountResponse[]> {
|
|
718
|
+
if (rows.length === 0) return [];
|
|
719
|
+
const accountIds = rows.map((row) => row.id);
|
|
720
|
+
// Today's per-account usage is one GROUP BY, not a read of every event
|
|
721
|
+
// since midnight. The read is scoped to the accounts on this page via the
|
|
722
|
+
// `(tenant, account, created_at)` index — a full-tenant breakdown scanned
|
|
723
|
+
// every of today's events and capped at 100 groups, so an account past
|
|
724
|
+
// the cap silently read as "no usage today".
|
|
725
|
+
const todayGroups = await this.accountTodayUsage(tenantId, accountIds);
|
|
726
|
+
const todayByAccount = new Map(
|
|
727
|
+
todayGroups.map((group) => [
|
|
728
|
+
group.key,
|
|
729
|
+
{
|
|
730
|
+
requests: group.requests,
|
|
731
|
+
errors: group.errors,
|
|
732
|
+
inputTokens: group.inputTokens,
|
|
733
|
+
outputTokens: group.outputTokens,
|
|
734
|
+
},
|
|
735
|
+
]),
|
|
736
|
+
);
|
|
737
|
+
const [lifetimeRows] = await Promise.all([
|
|
738
|
+
this.db
|
|
739
|
+
.select()
|
|
740
|
+
.from(telemetryUsageTotals)
|
|
741
|
+
.where(
|
|
742
|
+
and(
|
|
743
|
+
eq(telemetryUsageTotals.tenantId, tenantId),
|
|
744
|
+
eq(telemetryUsageTotals.identityType, "account"),
|
|
745
|
+
inArray(telemetryUsageTotals.entityId, accountIds),
|
|
746
|
+
),
|
|
747
|
+
),
|
|
748
|
+
]);
|
|
749
|
+
const lifetimeByAccount = new Map(lifetimeRows.map((row) => [row.entityId, row]));
|
|
750
|
+
const mapUsage = (row: {
|
|
751
|
+
readonly requests: number | string | null;
|
|
752
|
+
readonly errors: number | string | null;
|
|
753
|
+
readonly inputTokens: number | string | null;
|
|
754
|
+
readonly outputTokens: number | string | null;
|
|
755
|
+
} | undefined): ProviderAccountTokenUsage => {
|
|
756
|
+
const inputTokens = Number(row?.inputTokens ?? 0);
|
|
757
|
+
const outputTokens = Number(row?.outputTokens ?? 0);
|
|
758
|
+
return {
|
|
759
|
+
requests: Number(row?.requests ?? 0),
|
|
760
|
+
errors: Number(row?.errors ?? 0),
|
|
761
|
+
inputTokens,
|
|
762
|
+
outputTokens,
|
|
763
|
+
totalTokens: inputTokens + outputTokens,
|
|
764
|
+
};
|
|
765
|
+
};
|
|
766
|
+
const first = rows[0];
|
|
767
|
+
const inflightByAccount = await this.accountInflightFor(tenantId, first?.providerId);
|
|
768
|
+
return rows.map((row) => {
|
|
769
|
+
const inflight = inflightByAccount.get(row.id);
|
|
770
|
+
return this.mapAccount(row, {
|
|
771
|
+
today: mapUsage(todayByAccount.get(row.id)),
|
|
772
|
+
allTime: mapUsage(lifetimeByAccount.get(row.id)),
|
|
773
|
+
...(inflight === undefined ? {} : { inflight }),
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Today's per-account usage for a concrete id set. Scoped to those ids so
|
|
780
|
+
* the `(tenant, account, created_at)` index serves the range instead of a
|
|
781
|
+
* full-tenant window scan; returns exactly one group per account with
|
|
782
|
+
* traffic, so an account is never lost to a top-N cap.
|
|
783
|
+
*/
|
|
784
|
+
private async accountTodayUsage(
|
|
785
|
+
tenantId: string,
|
|
786
|
+
accountIds: readonly string[],
|
|
787
|
+
): Promise<
|
|
788
|
+
Array<{ key: string; requests: number; errors: number; inputTokens: number; outputTokens: number }>
|
|
789
|
+
> {
|
|
790
|
+
if (accountIds.length === 0) return [];
|
|
791
|
+
const todayStart = new Date();
|
|
792
|
+
todayStart.setUTCHours(0, 0, 0, 0);
|
|
793
|
+
const rows = await this.db
|
|
794
|
+
.select({
|
|
795
|
+
accountId: telemetryEvents.accountId,
|
|
796
|
+
requests: sql<number>`count(*)`,
|
|
797
|
+
errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
|
|
798
|
+
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
799
|
+
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
800
|
+
})
|
|
801
|
+
.from(telemetryEvents)
|
|
802
|
+
.where(
|
|
803
|
+
and(
|
|
804
|
+
eq(telemetryEvents.tenantId, tenantId),
|
|
805
|
+
gte(telemetryEvents.createdAt, todayStart),
|
|
806
|
+
inArray(telemetryEvents.accountId, accountIds),
|
|
807
|
+
),
|
|
808
|
+
)
|
|
809
|
+
.groupBy(telemetryEvents.accountId);
|
|
810
|
+
return rows.flatMap((row) => {
|
|
811
|
+
if (row.accountId === null) return [];
|
|
812
|
+
return [
|
|
813
|
+
{
|
|
814
|
+
key: row.accountId,
|
|
815
|
+
requests: Number(row.requests),
|
|
816
|
+
errors: Number(row.errors),
|
|
817
|
+
inputTokens: Number(row.inputTokens),
|
|
818
|
+
outputTokens: Number(row.outputTokens),
|
|
819
|
+
},
|
|
820
|
+
];
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
private async accountInflightFor(
|
|
825
|
+
tenantId: string,
|
|
826
|
+
providerId: string | undefined,
|
|
827
|
+
): Promise<Map<string, number>> {
|
|
828
|
+
const readings = new Map<string, number>();
|
|
829
|
+
if (!this.readAccountInflight || !providerId) return readings;
|
|
830
|
+
const rows = await this.readAccountInflight(providerId, tenantId);
|
|
831
|
+
for (const row of rows) {
|
|
832
|
+
if (typeof row.accountId !== "string" || !Number.isFinite(row.inflight)) continue;
|
|
833
|
+
readings.set(row.accountId, Math.max(0, Math.floor(row.inflight)));
|
|
834
|
+
}
|
|
835
|
+
return readings;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Next free list position for a new account in this provider's list.
|
|
840
|
+
*
|
|
841
|
+
* Scoped to (provider, tenant) because the list is per provider: a tenant
|
|
842
|
+
* with accounts under two providers must not have the second provider's
|
|
843
|
+
* first account land at a high index.
|
|
844
|
+
*/
|
|
845
|
+
/**
|
|
846
|
+
* Labels already in use for one provider, for numbering a generated name.
|
|
847
|
+
*
|
|
848
|
+
* Scoped to this provider and tenant: `dcint-key-1` and `other-key-1` belong
|
|
849
|
+
* to different providers and must not collide with each other.
|
|
850
|
+
*/
|
|
851
|
+
private async generateAccountLabel(
|
|
852
|
+
db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
853
|
+
providerId: string,
|
|
854
|
+
tenantId: string,
|
|
855
|
+
credentialMode: CredentialMode,
|
|
856
|
+
): Promise<string> {
|
|
857
|
+
const rows = await db
|
|
858
|
+
.select({ label: providerAccounts.label })
|
|
859
|
+
.from(providerAccounts)
|
|
860
|
+
.where(
|
|
861
|
+
and(
|
|
862
|
+
eq(providerAccounts.providerId, providerId),
|
|
863
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
864
|
+
),
|
|
865
|
+
);
|
|
866
|
+
return nextGeneratedAccountLabel(
|
|
867
|
+
providerId,
|
|
868
|
+
credentialMode,
|
|
869
|
+
rows.map((row) => row.label).filter((label): label is string => typeof label === "string"),
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
private async nextAccountSortIndex(
|
|
874
|
+
db: CartethyiaDatabase | Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
875
|
+
providerId: string,
|
|
876
|
+
tenantId: string,
|
|
877
|
+
): Promise<number> {
|
|
878
|
+
return nextSortIndex(
|
|
879
|
+
db,
|
|
880
|
+
{ table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
|
|
881
|
+
and(
|
|
882
|
+
eq(providerAccounts.providerId, providerId),
|
|
883
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
884
|
+
),
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** Rewrites one provider's account order; `accountIds` is the full order. */
|
|
889
|
+
async reorderAccounts(
|
|
890
|
+
tenantId: string,
|
|
891
|
+
providerId: string,
|
|
892
|
+
accountIds: readonly string[],
|
|
893
|
+
): Promise<void> {
|
|
894
|
+
await reorderSortIndex(
|
|
895
|
+
this.db,
|
|
896
|
+
{ table: providerAccounts, idColumn: providerAccounts.id, sortIndexColumn: providerAccounts.sortIndex },
|
|
897
|
+
and(
|
|
898
|
+
eq(providerAccounts.providerId, providerId),
|
|
899
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
900
|
+
),
|
|
901
|
+
accountIds,
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
async listAccountAuthStates(
|
|
906
|
+
tenantId: string,
|
|
907
|
+
providerId: string,
|
|
908
|
+
accountIds: readonly string[],
|
|
909
|
+
): Promise<Readonly<Record<string, Readonly<Record<string, unknown>>>>> {
|
|
910
|
+
if (accountIds.length === 0) return {};
|
|
911
|
+
const rows = await this.db
|
|
912
|
+
.select({ id: providerAccounts.id, authState: providerAccounts.authState })
|
|
913
|
+
.from(providerAccounts)
|
|
914
|
+
.where(
|
|
915
|
+
and(
|
|
916
|
+
eq(providerAccounts.providerId, providerId),
|
|
917
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
918
|
+
inArray(providerAccounts.id, accountIds),
|
|
919
|
+
),
|
|
920
|
+
);
|
|
921
|
+
const out: Record<string, Readonly<Record<string, unknown>>> = {};
|
|
922
|
+
for (const row of rows) {
|
|
923
|
+
if (row.authState !== null && typeof row.authState === "object" && !Array.isArray(row.authState)) {
|
|
924
|
+
out[row.id] = row.authState as Record<string, unknown>;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
async listAccounts(
|
|
931
|
+
tenantId: string,
|
|
932
|
+
providerId: string,
|
|
933
|
+
): Promise<readonly ProviderAccountResponse[]> {
|
|
934
|
+
const rows = await this.db
|
|
935
|
+
.select()
|
|
936
|
+
.from(providerAccounts)
|
|
937
|
+
.where(
|
|
938
|
+
and(
|
|
939
|
+
eq(providerAccounts.providerId, providerId),
|
|
940
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
941
|
+
),
|
|
942
|
+
)
|
|
943
|
+
// Stable list position, not `created_at`: two accounts created in the
|
|
944
|
+
// same millisecond could swap between loads. `id` breaks a tie so the
|
|
945
|
+
// order is total even before a reorder assigns distinct indices.
|
|
946
|
+
.orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
|
|
947
|
+
const accounts = await this.accountsWithUsage(tenantId, rows);
|
|
948
|
+
const poolAccounts = await this.listPoolAccounts(tenantId, providerId);
|
|
949
|
+
if (poolAccounts.length === 0) return accounts;
|
|
950
|
+
// Pool-as-account rows are appended after real accounts. They are
|
|
951
|
+
// account *equivalents* for ip-scoped, credential-less providers, so the
|
|
952
|
+
// operator sees the egresses that carry those providers' rate limits in
|
|
953
|
+
// the same list that already explains health.
|
|
954
|
+
return [...accounts, ...poolAccounts];
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* One cheap id-only read of an account set, for callers that need the set
|
|
959
|
+
* (e.g. the routing-inflight reader) without the usage aggregates
|
|
960
|
+
* `listAccounts` computes (P-02). No ORDER BY: the set is order-insensitive.
|
|
961
|
+
*/
|
|
962
|
+
async listAccountIds(
|
|
963
|
+
tenantId: string,
|
|
964
|
+
providerId: string,
|
|
965
|
+
): Promise<readonly string[]> {
|
|
966
|
+
const rows = await this.db
|
|
967
|
+
.select({ id: providerAccounts.id })
|
|
968
|
+
.from(providerAccounts)
|
|
969
|
+
.where(
|
|
970
|
+
and(
|
|
971
|
+
eq(providerAccounts.providerId, providerId),
|
|
972
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
973
|
+
),
|
|
974
|
+
);
|
|
975
|
+
return rows.map((row) => row.id);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async listAllAccounts(tenantId: string): Promise<readonly ProviderAccountResponse[]> {
|
|
979
|
+
const rows = await this.db
|
|
980
|
+
.select()
|
|
981
|
+
.from(providerAccounts)
|
|
982
|
+
.where(globalOrOwnedBy(providerAccounts.tenantId, tenantId))
|
|
983
|
+
.orderBy(asc(providerAccounts.sortIndex), asc(providerAccounts.createdAt), asc(providerAccounts.id));
|
|
984
|
+
const accounts = await this.accountsWithUsage(tenantId, rows);
|
|
985
|
+
const poolAccounts = await this.listAllPoolAccounts(tenantId);
|
|
986
|
+
if (poolAccounts.length === 0) return accounts;
|
|
987
|
+
return [...accounts, ...poolAccounts];
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
async listAllAccountSummaries(
|
|
991
|
+
tenantId: string,
|
|
992
|
+
): Promise<readonly { providerId: string; status: string }[]> {
|
|
993
|
+
const rows = await this.db
|
|
994
|
+
.select({ providerId: providerAccounts.providerId, status: providerAccounts.status })
|
|
995
|
+
.from(providerAccounts)
|
|
996
|
+
.where(globalOrOwnedBy(providerAccounts.tenantId, tenantId));
|
|
997
|
+
return rows.map((row) => ({ providerId: row.providerId, status: row.status }));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Per-provider account totals for one tenant, from a single grouped read (one
|
|
1002
|
+
* statement, served by the `(provider_id, tenant_id)` index). `active` counts
|
|
1003
|
+
* every status except `disabled` — an account parked by the health machine
|
|
1004
|
+
* (`cooldown`) is still a connection, matching how the account-overview badge
|
|
1005
|
+
* reads status. Used by the provider list overview (`accountCount` /
|
|
1006
|
+
* `activeAccountCount`); totals are never counted in a per-provider loop.
|
|
1007
|
+
*/
|
|
1008
|
+
async providerAccountCounts(
|
|
1009
|
+
tenantId: string,
|
|
1010
|
+
): Promise<readonly { providerId: string; total: number; active: number }[]> {
|
|
1011
|
+
const rows = await this.db
|
|
1012
|
+
.select({
|
|
1013
|
+
providerId: providerAccounts.providerId,
|
|
1014
|
+
total: sql<number>`count(*)`,
|
|
1015
|
+
active: sql<number>`sum(case when ${providerAccounts.status} <> 'disabled' then 1 else 0 end)`,
|
|
1016
|
+
})
|
|
1017
|
+
.from(providerAccounts)
|
|
1018
|
+
.where(globalOrOwnedBy(providerAccounts.tenantId, tenantId))
|
|
1019
|
+
.groupBy(providerAccounts.providerId);
|
|
1020
|
+
return rows.map((row) => ({
|
|
1021
|
+
providerId: row.providerId,
|
|
1022
|
+
total: Number(row.total),
|
|
1023
|
+
active: Number(row.active),
|
|
1024
|
+
}));
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
/**
|
|
1028
|
+
* Per-provider enabled model-row counts for one tenant, from a single grouped
|
|
1029
|
+
* read over `models ⋈ providers`. A model counts only when the row is enabled
|
|
1030
|
+
* and the tenant has not suppressed it via `tenant_disabled_models` — the
|
|
1031
|
+
* same visibility rule the per-provider model listing applies, so the
|
|
1032
|
+
* overview badge never disagrees with what the detail page would list.
|
|
1033
|
+
*/
|
|
1034
|
+
async providerModelCounts(
|
|
1035
|
+
tenantId: string,
|
|
1036
|
+
): Promise<readonly { providerId: string; count: number }[]> {
|
|
1037
|
+
const rows = await this.db
|
|
1038
|
+
.select({ providerId: models.providerId, count: sql<number>`count(*)` })
|
|
1039
|
+
.from(models)
|
|
1040
|
+
.innerJoin(providers, eq(models.providerId, providers.id))
|
|
1041
|
+
.leftJoin(
|
|
1042
|
+
tenantDisabledModels,
|
|
1043
|
+
and(
|
|
1044
|
+
eq(tenantDisabledModels.tenantId, tenantId),
|
|
1045
|
+
eq(tenantDisabledModels.providerId, models.providerId),
|
|
1046
|
+
eq(tenantDisabledModels.modelId, models.modelId),
|
|
1047
|
+
eq(tenantDisabledModels.endpointPath, models.endpointPath),
|
|
1048
|
+
),
|
|
1049
|
+
)
|
|
1050
|
+
.where(
|
|
1051
|
+
and(
|
|
1052
|
+
eq(models.enabled, true),
|
|
1053
|
+
globalOrOwnedBy(providers.tenantId, tenantId),
|
|
1054
|
+
isNull(tenantDisabledModels.id),
|
|
1055
|
+
),
|
|
1056
|
+
)
|
|
1057
|
+
.groupBy(models.providerId);
|
|
1058
|
+
return rows.map((row) => ({ providerId: row.providerId, count: Number(row.count) }));
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Pool-as-account rows for one provider. Only providers whose rate limit
|
|
1063
|
+
* follows the egress address materialize these; every other provider's pool
|
|
1064
|
+
* list stays empty, so real accounts are returned unchanged.
|
|
1065
|
+
*
|
|
1066
|
+
* The pool row is the source of health truth: `provider_pool_accounts`
|
|
1067
|
+
* rows are created at backfill and never mutated, so the surface reads the
|
|
1068
|
+
* owning pool's live status/last-* fields and lives on top of them.
|
|
1069
|
+
*/
|
|
1070
|
+
private async listPoolAccounts(
|
|
1071
|
+
tenantId: string,
|
|
1072
|
+
providerId: string,
|
|
1073
|
+
): Promise<readonly ProviderAccountResponse[]> {
|
|
1074
|
+
if (!providerRateLimitIsIpScoped(providerId)) return [];
|
|
1075
|
+
const rows = await this.db
|
|
1076
|
+
.select()
|
|
1077
|
+
.from(providerPoolAccounts)
|
|
1078
|
+
.where(
|
|
1079
|
+
and(
|
|
1080
|
+
eq(providerPoolAccounts.providerId, providerId),
|
|
1081
|
+
eq(providerPoolAccounts.tenantId, tenantId),
|
|
1082
|
+
),
|
|
1083
|
+
)
|
|
1084
|
+
.orderBy(asc(providerPoolAccounts.createdAt), asc(providerPoolAccounts.id));
|
|
1085
|
+
if (rows.length === 0) return [];
|
|
1086
|
+
const pools = await this.poolsById(tenantId, rows.map((row) => row.networkPoolId));
|
|
1087
|
+
const usage = await this.poolUsage(tenantId, rows.map((row) => row.networkPoolId));
|
|
1088
|
+
return rows.map((row) => this.mapPoolAccount(row, pools.get(row.networkPoolId), usage.get(row.networkPoolId)));
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/** Pool-as-account rows across every provider, for tenant-wide listings. */
|
|
1092
|
+
private async listAllPoolAccounts(
|
|
1093
|
+
tenantId: string,
|
|
1094
|
+
): Promise<readonly ProviderAccountResponse[]> {
|
|
1095
|
+
const rows = await this.db
|
|
1096
|
+
.select()
|
|
1097
|
+
.from(providerPoolAccounts)
|
|
1098
|
+
.where(eq(providerPoolAccounts.tenantId, tenantId))
|
|
1099
|
+
.orderBy(asc(providerPoolAccounts.createdAt), asc(providerPoolAccounts.id));
|
|
1100
|
+
if (rows.length === 0) return [];
|
|
1101
|
+
const pools = await this.poolsById(tenantId, rows.map((row) => row.networkPoolId));
|
|
1102
|
+
const usage = await this.poolUsage(tenantId, rows.map((row) => row.networkPoolId));
|
|
1103
|
+
return rows.map((row) => this.mapPoolAccount(row, pools.get(row.networkPoolId), usage.get(row.networkPoolId)));
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Selects the owning network pools for a set of pool-account rows. */
|
|
1107
|
+
private async poolsById(
|
|
1108
|
+
tenantId: string,
|
|
1109
|
+
poolIds: readonly string[],
|
|
1110
|
+
): Promise<Map<string, typeof networkPools.$inferSelect>> {
|
|
1111
|
+
const map = new Map<string, typeof networkPools.$inferSelect>();
|
|
1112
|
+
if (poolIds.length === 0) return map;
|
|
1113
|
+
const rows = await this.db
|
|
1114
|
+
.select()
|
|
1115
|
+
.from(networkPools)
|
|
1116
|
+
.where(
|
|
1117
|
+
and(
|
|
1118
|
+
eq(networkPools.tenantId, tenantId),
|
|
1119
|
+
inArray(networkPools.id, poolIds),
|
|
1120
|
+
),
|
|
1121
|
+
);
|
|
1122
|
+
for (const row of rows) map.set(row.id, row);
|
|
1123
|
+
return map;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Today + lifetime token usage per pool, from telemetry. Pools carry no
|
|
1128
|
+
* rows in `telemetry_usage_totals` (that table only admits account/api_key
|
|
1129
|
+
* identity types), so lifetime here is aggregated from the retained event
|
|
1130
|
+
* window (the same bounded window the usage page reads) rather than from
|
|
1131
|
+
* durable rollups.
|
|
1132
|
+
*/
|
|
1133
|
+
private async poolUsage(
|
|
1134
|
+
tenantId: string,
|
|
1135
|
+
poolIds: readonly string[],
|
|
1136
|
+
): Promise<Map<string, { today: ProviderAccountTokenUsage; allTime: ProviderAccountTokenUsage }>> {
|
|
1137
|
+
const map = new Map<string, { today: ProviderAccountTokenUsage; allTime: ProviderAccountTokenUsage }>();
|
|
1138
|
+
if (poolIds.length === 0) return map;
|
|
1139
|
+
const todayStart = new Date();
|
|
1140
|
+
todayStart.setUTCHours(0, 0, 0, 0);
|
|
1141
|
+
const [todayRows, lifetimeRows] = await Promise.all([
|
|
1142
|
+
this.db
|
|
1143
|
+
.select({
|
|
1144
|
+
poolId: telemetryEvents.networkPoolId,
|
|
1145
|
+
requests: sql<number>`count(*)`,
|
|
1146
|
+
errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
|
|
1147
|
+
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
1148
|
+
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
1149
|
+
})
|
|
1150
|
+
.from(telemetryEvents)
|
|
1151
|
+
.where(
|
|
1152
|
+
and(
|
|
1153
|
+
eq(telemetryEvents.tenantId, tenantId),
|
|
1154
|
+
gte(telemetryEvents.createdAt, todayStart),
|
|
1155
|
+
inArray(telemetryEvents.networkPoolId, poolIds),
|
|
1156
|
+
),
|
|
1157
|
+
)
|
|
1158
|
+
.groupBy(telemetryEvents.networkPoolId),
|
|
1159
|
+
this.db
|
|
1160
|
+
.select({
|
|
1161
|
+
poolId: telemetryEvents.networkPoolId,
|
|
1162
|
+
requests: sql<number>`count(*)`,
|
|
1163
|
+
errors: sql<number>`sum(case when ${IS_GATEWAY_ERROR_SQL} then 1 else 0 end)`,
|
|
1164
|
+
inputTokens: sql<number>`coalesce(sum(${telemetryEvents.inputTokens}), 0)`,
|
|
1165
|
+
outputTokens: sql<number>`coalesce(sum(${telemetryEvents.outputTokens}), 0)`,
|
|
1166
|
+
})
|
|
1167
|
+
.from(telemetryEvents)
|
|
1168
|
+
.where(
|
|
1169
|
+
and(
|
|
1170
|
+
eq(telemetryEvents.tenantId, tenantId),
|
|
1171
|
+
inArray(telemetryEvents.networkPoolId, poolIds),
|
|
1172
|
+
),
|
|
1173
|
+
)
|
|
1174
|
+
.groupBy(telemetryEvents.networkPoolId),
|
|
1175
|
+
]);
|
|
1176
|
+
const toUsage = (row: {
|
|
1177
|
+
readonly requests: number | string | null;
|
|
1178
|
+
readonly errors: number | string | null;
|
|
1179
|
+
readonly inputTokens: number | string | null;
|
|
1180
|
+
readonly outputTokens: number | string | null;
|
|
1181
|
+
}): ProviderAccountTokenUsage => {
|
|
1182
|
+
const inputTokens = Number(row.inputTokens ?? 0);
|
|
1183
|
+
const outputTokens = Number(row.outputTokens ?? 0);
|
|
1184
|
+
return {
|
|
1185
|
+
requests: Number(row.requests ?? 0),
|
|
1186
|
+
errors: Number(row.errors ?? 0),
|
|
1187
|
+
inputTokens,
|
|
1188
|
+
outputTokens,
|
|
1189
|
+
totalTokens: inputTokens + outputTokens,
|
|
1190
|
+
};
|
|
1191
|
+
};
|
|
1192
|
+
for (const id of poolIds) {
|
|
1193
|
+
const today = todayRows.find((row) => row.poolId === id);
|
|
1194
|
+
const lifetime = lifetimeRows.find((row) => row.poolId === id);
|
|
1195
|
+
map.set(id, {
|
|
1196
|
+
today: today ? toUsage(today) : emptyUsage,
|
|
1197
|
+
allTime: lifetime ? toUsage(lifetime) : emptyUsage,
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
return map;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
private mapPoolAccount(
|
|
1204
|
+
row: typeof providerPoolAccounts.$inferSelect,
|
|
1205
|
+
pool: typeof networkPools.$inferSelect | undefined,
|
|
1206
|
+
usage: { today: ProviderAccountTokenUsage; allTime: ProviderAccountTokenUsage } | undefined,
|
|
1207
|
+
): ProviderAccountResponse {
|
|
1208
|
+
return {
|
|
1209
|
+
id: row.id,
|
|
1210
|
+
providerId: row.providerId,
|
|
1211
|
+
tenantId: row.tenantId,
|
|
1212
|
+
label: row.label,
|
|
1213
|
+
credentialKind: "none",
|
|
1214
|
+
// Health truth lives on the owning pool; fall back to the row only when
|
|
1215
|
+
// the pool row is unreachable (deleted cascade would have removed the
|
|
1216
|
+
// account row, so this is defensive).
|
|
1217
|
+
status: pool?.status ?? row.status,
|
|
1218
|
+
usageToday: usage?.today ?? emptyUsage,
|
|
1219
|
+
usageAllTime: usage?.allTime ?? emptyUsage,
|
|
1220
|
+
consecutiveFailures: pool?.consecutiveFailures ?? row.consecutiveFailures,
|
|
1221
|
+
...(pool?.lastSuccessAt
|
|
1222
|
+
? { lastSuccessAt: pool.lastSuccessAt.toISOString() }
|
|
1223
|
+
: row.lastSuccessAt
|
|
1224
|
+
? { lastSuccessAt: row.lastSuccessAt.toISOString() }
|
|
1225
|
+
: {}),
|
|
1226
|
+
...(pool?.lastError ? { lastError: pool.lastError } : row.lastError ? { lastError: row.lastError } : {}),
|
|
1227
|
+
...(pool?.lastErrorCategory
|
|
1228
|
+
? { lastErrorCategory: pool.lastErrorCategory }
|
|
1229
|
+
: row.lastErrorCategory
|
|
1230
|
+
? { lastErrorCategory: row.lastErrorCategory }
|
|
1231
|
+
: {}),
|
|
1232
|
+
...(pool?.lastErrorAt
|
|
1233
|
+
? { lastErrorAt: pool.lastErrorAt.toISOString() }
|
|
1234
|
+
: row.lastErrorAt
|
|
1235
|
+
? { lastErrorAt: row.lastErrorAt.toISOString() }
|
|
1236
|
+
: {}),
|
|
1237
|
+
...(pool?.cooldownUntil
|
|
1238
|
+
? { cooldownUntil: pool.cooldownUntil.toISOString() }
|
|
1239
|
+
: row.cooldownUntil
|
|
1240
|
+
? { cooldownUntil: row.cooldownUntil.toISOString() }
|
|
1241
|
+
: {}),
|
|
1242
|
+
...(pool?.lastRecoveredAt
|
|
1243
|
+
? { lastRecoveredAt: pool.lastRecoveredAt.toISOString() }
|
|
1244
|
+
: row.lastRecoveredAt
|
|
1245
|
+
? { lastRecoveredAt: row.lastRecoveredAt.toISOString() }
|
|
1246
|
+
: {}),
|
|
1247
|
+
createdAt: row.createdAt.toISOString(),
|
|
1248
|
+
sortIndex: 0,
|
|
1249
|
+
kind: "pool",
|
|
1250
|
+
networkPoolId: row.networkPoolId,
|
|
1251
|
+
...(pool?.providerCooldowns
|
|
1252
|
+
? { providerCooldowns: activePoolProviderCooldowns(pool.providerCooldowns) }
|
|
1253
|
+
: {}),
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async createAccount(
|
|
1258
|
+
tenantId: string,
|
|
1259
|
+
providerId: string,
|
|
1260
|
+
request: CreateProviderAccountRequest,
|
|
1261
|
+
existingTx?: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
1262
|
+
includeUsage = true,
|
|
1263
|
+
sortIndexOverride?: number,
|
|
1264
|
+
): Promise<ProviderAccountResponse> {
|
|
1265
|
+
// Callers inside an existing transaction pass it in so the account lands in
|
|
1266
|
+
// that unit of work (the batch path); otherwise this opens its own.
|
|
1267
|
+
const run = async (
|
|
1268
|
+
tx: Parameters<Parameters<CartethyiaDatabase["transaction"]>[0]>[0],
|
|
1269
|
+
): Promise<ProviderAccountResponse> => {
|
|
1270
|
+
let effectiveSecret = request.secret;
|
|
1271
|
+
let effectiveCredentialKind = request.credentialKind;
|
|
1272
|
+
const requestedCredentialMode: CredentialMode = request.credentialMode ?? "auto";
|
|
1273
|
+
let effectiveCredentialMode: CredentialMode = requestedCredentialMode;
|
|
1274
|
+
let tokenExpiresAt: Date | undefined;
|
|
1275
|
+
let jwtIssuer: string | undefined;
|
|
1276
|
+
let oauthRefreshCiphertext: Buffer | undefined;
|
|
1277
|
+
let oauthExpiresAt: Date | undefined;
|
|
1278
|
+
let oauthHasRefreshToken = false;
|
|
1279
|
+
let pastedAuthState: Readonly<Record<string, unknown>> = {};
|
|
1280
|
+
|
|
1281
|
+
if (
|
|
1282
|
+
(request.credentialKind === "oauth" || request.credentialKind === "api_key") &&
|
|
1283
|
+
request.secret.trim().length > 0
|
|
1284
|
+
) {
|
|
1285
|
+
const trimmed = request.secret.trim();
|
|
1286
|
+
const parts = parsePastedOAuthCredential(trimmed);
|
|
1287
|
+
pastedAuthState = parts.authState;
|
|
1288
|
+
const hasRefreshToken = parts.refreshToken !== parts.accessToken;
|
|
1289
|
+
const parsedJwt =
|
|
1290
|
+
requestedCredentialMode === "api_key"
|
|
1291
|
+
? undefined
|
|
1292
|
+
: parseJwtCredential(parts.accessToken);
|
|
1293
|
+
if (requestedCredentialMode === "jwt" && parsedJwt === undefined) {
|
|
1294
|
+
throw new ConsoleDomainError(
|
|
1295
|
+
"invalid_request",
|
|
1296
|
+
400,
|
|
1297
|
+
"credentialMode jwt requires a valid three-part JWT",
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
if (requestedCredentialMode === "api_key") {
|
|
1301
|
+
effectiveSecret = parts.accessToken;
|
|
1302
|
+
effectiveCredentialKind = "api_key";
|
|
1303
|
+
effectiveCredentialMode = "api_key";
|
|
1304
|
+
} else if (
|
|
1305
|
+
parsedJwt !== undefined &&
|
|
1306
|
+
(requestedCredentialMode === "jwt" || !hasRefreshToken)
|
|
1307
|
+
) {
|
|
1308
|
+
effectiveSecret = parts.accessToken;
|
|
1309
|
+
effectiveCredentialKind = "api_key";
|
|
1310
|
+
effectiveCredentialMode = "jwt";
|
|
1311
|
+
tokenExpiresAt = parsedJwt.expiresAt;
|
|
1312
|
+
// Kept alongside the expiry: the console shows who issued a static
|
|
1313
|
+
// token, and the secret itself is never readable after this point.
|
|
1314
|
+
jwtIssuer = parsedJwt.issuer;
|
|
1315
|
+
} else if (request.credentialKind === "oauth" || hasRefreshToken) {
|
|
1316
|
+
effectiveSecret = parts.accessToken;
|
|
1317
|
+
effectiveCredentialKind = hasRefreshToken ? "oauth" : "api_key";
|
|
1318
|
+
effectiveCredentialMode = hasRefreshToken ? "auto" : "api_key";
|
|
1319
|
+
if (hasRefreshToken) {
|
|
1320
|
+
oauthRefreshCiphertext = encryptCredential(parts.refreshToken);
|
|
1321
|
+
oauthHasRefreshToken = true;
|
|
1322
|
+
}
|
|
1323
|
+
oauthExpiresAt = parts.expiresAt;
|
|
1324
|
+
} else {
|
|
1325
|
+
effectiveCredentialMode = "api_key";
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Append to the end of this provider's list rather than relying on
|
|
1330
|
+
// creation time, so a new account never displaces existing positions.
|
|
1331
|
+
const credentialFingerprint =
|
|
1332
|
+
effectiveCredentialKind === "none" || effectiveSecret.length === 0
|
|
1333
|
+
? undefined
|
|
1334
|
+
: hashSecret(effectiveSecret);
|
|
1335
|
+
const accountAuthState = {
|
|
1336
|
+
...pastedAuthState,
|
|
1337
|
+
...(request.authState ?? {}),
|
|
1338
|
+
credentialMode: effectiveCredentialMode,
|
|
1339
|
+
...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt: tokenExpiresAt.toISOString() }),
|
|
1340
|
+
...(jwtIssuer === undefined ? {} : { jwtIssuer }),
|
|
1341
|
+
};
|
|
1342
|
+
const nextSortIndex =
|
|
1343
|
+
sortIndexOverride ?? (await this.nextAccountSortIndex(tx, providerId, tenantId));
|
|
1344
|
+
// Read existing labels only when one has to be generated: a named
|
|
1345
|
+
// account costs no extra query, and this is the path a large batch
|
|
1346
|
+
// paste walks once per row.
|
|
1347
|
+
const label =
|
|
1348
|
+
request.label ??
|
|
1349
|
+
(await this.generateAccountLabel(tx, providerId, tenantId, effectiveCredentialMode));
|
|
1350
|
+
const rows = await tx
|
|
1351
|
+
.insert(providerAccounts)
|
|
1352
|
+
.values({
|
|
1353
|
+
providerId,
|
|
1354
|
+
tenantId,
|
|
1355
|
+
sortIndex: nextSortIndex,
|
|
1356
|
+
label,
|
|
1357
|
+
credentialCiphertext: encryptCredential(effectiveSecret),
|
|
1358
|
+
...(credentialFingerprint ? { credentialFingerprint } : {}),
|
|
1359
|
+
credentialKind: effectiveCredentialKind,
|
|
1360
|
+
status: "active",
|
|
1361
|
+
authState: accountAuthState,
|
|
1362
|
+
// An OAuth account pasted without a refresh token is a *static*
|
|
1363
|
+
// token: the pasted value is a bearer token used exactly as issued
|
|
1364
|
+
// and there is no refresh grant to run. Flag it as static so the
|
|
1365
|
+
// console reads it as an informational "static token" rather than
|
|
1366
|
+
// "re-login required" — the token is still valid and dispatchable —
|
|
1367
|
+
// and so the refresh sweep skips it instead of retrying a refresh
|
|
1368
|
+
// that can never succeed.
|
|
1369
|
+
...(effectiveCredentialKind === "oauth" && !oauthHasRefreshToken || effectiveCredentialMode === "jwt"
|
|
1370
|
+
? { staticToken: true }
|
|
1371
|
+
: {}),
|
|
1372
|
+
})
|
|
1373
|
+
.returning();
|
|
1374
|
+
const row = rows[0];
|
|
1375
|
+
if (!row) throw new Error("failed to create provider account");
|
|
1376
|
+
|
|
1377
|
+
if (effectiveCredentialKind === "oauth") {
|
|
1378
|
+
await tx.insert(providerOauthStates).values({
|
|
1379
|
+
providerAccountId: row.id,
|
|
1380
|
+
...(oauthRefreshCiphertext ? { refreshCiphertext: oauthRefreshCiphertext } : {}),
|
|
1381
|
+
expiresAt: oauthExpiresAt ?? null,
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
if (request.credentialKind === "oauth" && !oauthHasRefreshToken) {
|
|
1385
|
+
pushStructuredConsoleLog(
|
|
1386
|
+
"info",
|
|
1387
|
+
"OAuth access token stored as a static API credential; it will not be refreshed",
|
|
1388
|
+
{
|
|
1389
|
+
event: "token_refresh",
|
|
1390
|
+
providerId,
|
|
1391
|
+
accountId: row.id,
|
|
1392
|
+
errorCode: "static_token",
|
|
1393
|
+
},
|
|
1394
|
+
);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
if (!includeUsage) {
|
|
1398
|
+
const emptyUsage = { requests: 0, errors: 0, inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
1399
|
+
return this.mapAccount(row, { today: emptyUsage, allTime: emptyUsage });
|
|
1400
|
+
}
|
|
1401
|
+
const [account] = await this.accountsWithUsage(tenantId, [row]);
|
|
1402
|
+
if (!account) throw new Error("created provider account could not be mapped");
|
|
1403
|
+
return account;
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
try {
|
|
1407
|
+
if (existingTx !== undefined) {
|
|
1408
|
+
return await run(existingTx);
|
|
1409
|
+
}
|
|
1410
|
+
return await this.db.transaction(run);
|
|
1411
|
+
} catch (error) {
|
|
1412
|
+
if (isUniqueViolation(error)) {
|
|
1413
|
+
throw new ConsoleDomainError(
|
|
1414
|
+
"provider_account_duplicate",
|
|
1415
|
+
409,
|
|
1416
|
+
"A provider account with this credential already exists",
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
throw error;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Creates many accounts in one round-trip.
|
|
1425
|
+
*
|
|
1426
|
+
* The whole batch shares one transaction, so a paste of 40 credentials costs
|
|
1427
|
+
* one connection checkout rather than 40. Each item is created through the
|
|
1428
|
+
* same `createAccount` path the single route uses — there is no second
|
|
1429
|
+
* implementation of credential detection to drift.
|
|
1430
|
+
*
|
|
1431
|
+
* Items are created sequentially inside that transaction: the unique
|
|
1432
|
+
* credential fingerprint makes two identical pastes collide, and a nested
|
|
1433
|
+
* transaction per item keeps that collision from aborting the whole batch.
|
|
1434
|
+
* Outcomes are reported per index, so a partial paste still lands the
|
|
1435
|
+
* accounts that were fine — an operator fixing one bad line should not have
|
|
1436
|
+
* to re-import 39.
|
|
1437
|
+
*/
|
|
1438
|
+
async createAccountBatch(
|
|
1439
|
+
tenantId: string,
|
|
1440
|
+
providerId: string,
|
|
1441
|
+
requests: readonly CreateProviderAccountRequest[],
|
|
1442
|
+
): Promise<CreateProviderAccountBatchResult> {
|
|
1443
|
+
if (requests.length === 0) {
|
|
1444
|
+
return { created: 0, failed: 0, results: [] };
|
|
1445
|
+
}
|
|
1446
|
+
const results: CreateProviderAccountBatchItemResult[] = [];
|
|
1447
|
+
let created = 0;
|
|
1448
|
+
let failed = 0;
|
|
1449
|
+
|
|
1450
|
+
await this.db.transaction(async (tx) => {
|
|
1451
|
+
let nextSortIndex = await this.nextAccountSortIndex(tx, providerId, tenantId);
|
|
1452
|
+
for (const [index, request] of requests.entries()) {
|
|
1453
|
+
try {
|
|
1454
|
+
// A nested transaction acts as a savepoint: a duplicate-fingerprint
|
|
1455
|
+
// violation rolls back only this item, not the accounts before it.
|
|
1456
|
+
const account = await tx.transaction(async (inner) =>
|
|
1457
|
+
this.createAccount(tenantId, providerId, request, inner, false, nextSortIndex),
|
|
1458
|
+
);
|
|
1459
|
+
created += 1;
|
|
1460
|
+
nextSortIndex += 1;
|
|
1461
|
+
results.push({ index, ok: true, ...(account ? { account } : {}) });
|
|
1462
|
+
} catch (error) {
|
|
1463
|
+
failed += 1;
|
|
1464
|
+
results.push({
|
|
1465
|
+
index,
|
|
1466
|
+
ok: false,
|
|
1467
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1468
|
+
code: error instanceof ConsoleDomainError ? error.code : "create_failed",
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1474
|
+
return { created, failed, results };
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
async updateAccount(
|
|
1478
|
+
tenantId: string,
|
|
1479
|
+
providerId: string,
|
|
1480
|
+
accountId: string,
|
|
1481
|
+
patch: UpdateProviderAccountRequest,
|
|
1482
|
+
): Promise<ProviderAccountResponse | undefined> {
|
|
1483
|
+
const set: Partial<typeof providerAccounts.$inferInsert> = {};
|
|
1484
|
+
if (patch.label !== undefined) set.label = patch.label;
|
|
1485
|
+
if (patch.secret !== undefined) {
|
|
1486
|
+
set.credentialCiphertext = encryptCredential(patch.secret);
|
|
1487
|
+
set.credentialFingerprint =
|
|
1488
|
+
patch.secret.length === 0 ? null : hashSecret(patch.secret);
|
|
1489
|
+
}
|
|
1490
|
+
if (patch.status !== undefined) set.status = patch.status;
|
|
1491
|
+
// Toggling the static-token flag: turning it on declares the credential is
|
|
1492
|
+
// used as issued and never refreshed, so any stale re-auth flag is cleared —
|
|
1493
|
+
// the operator has said the account is fine, and the pill must stop reading
|
|
1494
|
+
// "Re-login required". Turning it off returns the account to normal OAuth
|
|
1495
|
+
// refresh handling; the sweep picks it up again on its next pass.
|
|
1496
|
+
if (patch.staticToken !== undefined) {
|
|
1497
|
+
set.staticToken = patch.staticToken;
|
|
1498
|
+
if (patch.staticToken) {
|
|
1499
|
+
set.lastError = null;
|
|
1500
|
+
set.lastErrorCategory = null;
|
|
1501
|
+
set.lastErrorAt = null;
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
// Status toggles are not recovery: preserve the health machine's evidence
|
|
1505
|
+
// until the operator uses Recover or replaces the rejected credential.
|
|
1506
|
+
const clearsFailureState = patch.secret !== undefined && patch.secret.length > 0;
|
|
1507
|
+
if (clearsFailureState) {
|
|
1508
|
+
set.consecutiveFailures = 0;
|
|
1509
|
+
set.cooldownUntil = null;
|
|
1510
|
+
set.modelCooldowns = {};
|
|
1511
|
+
set.lastError = null;
|
|
1512
|
+
set.lastErrorCategory = null;
|
|
1513
|
+
set.lastErrorAt = null;
|
|
1514
|
+
}
|
|
1515
|
+
const where = and(
|
|
1516
|
+
eq(providerAccounts.id, accountId),
|
|
1517
|
+
eq(providerAccounts.providerId, providerId),
|
|
1518
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1519
|
+
);
|
|
1520
|
+
try {
|
|
1521
|
+
const rows =
|
|
1522
|
+
Object.keys(set).length > 0
|
|
1523
|
+
? await this.db.update(providerAccounts).set(set).where(where).returning()
|
|
1524
|
+
: await this.db.select().from(providerAccounts).where(where);
|
|
1525
|
+
const row = rows[0];
|
|
1526
|
+
if (!row) return undefined;
|
|
1527
|
+
// Secret/status mutation: drop any cached credential so the next
|
|
1528
|
+
// attempt decrypts the new ciphertext (or refuses a disabled account).
|
|
1529
|
+
if (patch.secret !== undefined || patch.status !== undefined) {
|
|
1530
|
+
invalidateCredentialCache(accountId);
|
|
1531
|
+
}
|
|
1532
|
+
const [account] = await this.accountsWithUsage(tenantId, [row]);
|
|
1533
|
+
return account;
|
|
1534
|
+
} catch (error) {
|
|
1535
|
+
if (isUniqueViolation(error)) {
|
|
1536
|
+
throw new ConsoleDomainError(
|
|
1537
|
+
"provider_account_duplicate",
|
|
1538
|
+
409,
|
|
1539
|
+
"A provider account with this credential already exists",
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
throw error;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
private mapAccount(
|
|
1547
|
+
row: typeof providerAccounts.$inferSelect,
|
|
1548
|
+
usage: {
|
|
1549
|
+
readonly today: ProviderAccountTokenUsage;
|
|
1550
|
+
readonly allTime: ProviderAccountTokenUsage;
|
|
1551
|
+
readonly inflight?: number | undefined;
|
|
1552
|
+
},
|
|
1553
|
+
): ProviderAccountResponse {
|
|
1554
|
+
const authState =
|
|
1555
|
+
row.authState !== null && typeof row.authState === "object" && !Array.isArray(row.authState)
|
|
1556
|
+
? (row.authState as Record<string, unknown>)
|
|
1557
|
+
: undefined;
|
|
1558
|
+
const rawCredentialMode = authState?.["credentialMode"];
|
|
1559
|
+
const credentialMode: CredentialMode | undefined =
|
|
1560
|
+
rawCredentialMode === "auto" || rawCredentialMode === "jwt" || rawCredentialMode === "api_key"
|
|
1561
|
+
? rawCredentialMode
|
|
1562
|
+
: undefined;
|
|
1563
|
+
const tokenExpiresAt =
|
|
1564
|
+
typeof authState?.["tokenExpiresAt"] === "string" ? authState["tokenExpiresAt"] : undefined;
|
|
1565
|
+
const jwtIssuer =
|
|
1566
|
+
typeof authState?.["jwtIssuer"] === "string" ? authState["jwtIssuer"] : undefined;
|
|
1567
|
+
const authMethod =
|
|
1568
|
+
typeof authState?.["authMethod"] === "string" ? authState["authMethod"] : undefined;
|
|
1569
|
+
const accountEmail = asStoredText(row.accountEmail) ?? undefined;
|
|
1570
|
+
const accountName =
|
|
1571
|
+
typeof authState?.["accountName"] === "string" && authState["accountName"].trim().length > 0
|
|
1572
|
+
? authState["accountName"].trim()
|
|
1573
|
+
: undefined;
|
|
1574
|
+
return {
|
|
1575
|
+
id: row.id,
|
|
1576
|
+
providerId: row.providerId,
|
|
1577
|
+
tenantId: row.tenantId,
|
|
1578
|
+
label: row.label,
|
|
1579
|
+
...(accountEmail === undefined ? {} : { accountEmail }),
|
|
1580
|
+
...(accountName === undefined ? {} : { accountName }),
|
|
1581
|
+
credentialKind: row.credentialKind,
|
|
1582
|
+
...(credentialMode === undefined ? {} : { credentialMode }),
|
|
1583
|
+
...(tokenExpiresAt === undefined ? {} : { tokenExpiresAt }),
|
|
1584
|
+
...(jwtIssuer === undefined ? {} : { jwtIssuer }),
|
|
1585
|
+
...(authMethod === undefined ? {} : { authMethod }),
|
|
1586
|
+
status: row.status,
|
|
1587
|
+
...(usage.inflight === undefined ? {} : { inflight: usage.inflight }),
|
|
1588
|
+
usageToday: usage.today,
|
|
1589
|
+
usageAllTime: usage.allTime,
|
|
1590
|
+
consecutiveFailures: row.consecutiveFailures,
|
|
1591
|
+
...(row.lastSuccessAt ? { lastSuccessAt: row.lastSuccessAt.toISOString() } : {}),
|
|
1592
|
+
...(row.lastError ? { lastError: row.lastError } : {}),
|
|
1593
|
+
...(row.lastErrorCategory ? { lastErrorCategory: row.lastErrorCategory } : {}),
|
|
1594
|
+
...(row.lastErrorAt ? { lastErrorAt: row.lastErrorAt.toISOString() } : {}),
|
|
1595
|
+
...(row.cooldownUntil ? { cooldownUntil: row.cooldownUntil.toISOString() } : {}),
|
|
1596
|
+
...(liveModelCooldowns(row.modelCooldowns) ?? {}),
|
|
1597
|
+
...(row.lastRecoveredAt ? { lastRecoveredAt: row.lastRecoveredAt.toISOString() } : {}),
|
|
1598
|
+
createdAt: row.createdAt.toISOString(),
|
|
1599
|
+
sortIndex: row.sortIndex,
|
|
1600
|
+
...(row.staticToken ? { staticToken: true } : {}),
|
|
1601
|
+
};
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
async listAccountHealthEvents(
|
|
1605
|
+
tenantId: string,
|
|
1606
|
+
providerId: string,
|
|
1607
|
+
accountId: string,
|
|
1608
|
+
): Promise<readonly AccountHealthEventRecord[]> {
|
|
1609
|
+
const owned = await this.db
|
|
1610
|
+
.select({ id: providerAccounts.id })
|
|
1611
|
+
.from(providerAccounts)
|
|
1612
|
+
.where(
|
|
1613
|
+
and(
|
|
1614
|
+
eq(providerAccounts.id, accountId),
|
|
1615
|
+
eq(providerAccounts.providerId, providerId),
|
|
1616
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1617
|
+
),
|
|
1618
|
+
)
|
|
1619
|
+
.limit(1);
|
|
1620
|
+
if (owned.length === 0) return [];
|
|
1621
|
+
return listAccountHealthEvents(this.db, accountId);
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** Deletes one account's stored health & error rows, for the modal's purge. */
|
|
1625
|
+
async purgeAccountHealthEvents(
|
|
1626
|
+
tenantId: string,
|
|
1627
|
+
providerId: string,
|
|
1628
|
+
accountId: string,
|
|
1629
|
+
): Promise<number> {
|
|
1630
|
+
const owned = await this.db
|
|
1631
|
+
.select({ id: providerAccounts.id })
|
|
1632
|
+
.from(providerAccounts)
|
|
1633
|
+
.where(
|
|
1634
|
+
and(
|
|
1635
|
+
eq(providerAccounts.id, accountId),
|
|
1636
|
+
eq(providerAccounts.providerId, providerId),
|
|
1637
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1638
|
+
),
|
|
1639
|
+
)
|
|
1640
|
+
.limit(1);
|
|
1641
|
+
if (owned.length === 0) return 0;
|
|
1642
|
+
return deleteAccountHealthEvents(this.db, accountId);
|
|
1643
|
+
}
|
|
1644
|
+
async listBuddyGrowthTasks(
|
|
1645
|
+
tenantId: string,
|
|
1646
|
+
providerId: string,
|
|
1647
|
+
accountId: string,
|
|
1648
|
+
dayKey: string,
|
|
1649
|
+
): Promise<readonly BuddyGrowthTaskLedgerEntry[]> {
|
|
1650
|
+
const owned = await this.db
|
|
1651
|
+
.select({ id: providerAccounts.id })
|
|
1652
|
+
.from(providerAccounts)
|
|
1653
|
+
.where(
|
|
1654
|
+
and(
|
|
1655
|
+
eq(providerAccounts.id, accountId),
|
|
1656
|
+
eq(providerAccounts.providerId, providerId),
|
|
1657
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1658
|
+
),
|
|
1659
|
+
)
|
|
1660
|
+
.limit(1);
|
|
1661
|
+
if (owned.length === 0) return [];
|
|
1662
|
+
const rows = await this.db
|
|
1663
|
+
.select({
|
|
1664
|
+
accountId: buddyTaskLedger.accountId,
|
|
1665
|
+
task: buddyTaskLedger.task,
|
|
1666
|
+
claimedAt: buddyTaskLedger.claimedAt,
|
|
1667
|
+
})
|
|
1668
|
+
.from(buddyTaskLedger)
|
|
1669
|
+
.where(
|
|
1670
|
+
and(
|
|
1671
|
+
eq(buddyTaskLedger.accountId, accountId),
|
|
1672
|
+
eq(buddyTaskLedger.dayKey, dayKey),
|
|
1673
|
+
),
|
|
1674
|
+
);
|
|
1675
|
+
return rows.map((row) => ({
|
|
1676
|
+
accountId: row.accountId,
|
|
1677
|
+
task: row.task,
|
|
1678
|
+
claimedAt: row.claimedAt.toISOString(),
|
|
1679
|
+
}));
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
async recoverAccount(
|
|
1683
|
+
tenantId: string,
|
|
1684
|
+
providerId: string,
|
|
1685
|
+
accountId: string,
|
|
1686
|
+
): Promise<boolean> {
|
|
1687
|
+
const owned = await this.db
|
|
1688
|
+
.select({ id: providerAccounts.id })
|
|
1689
|
+
.from(providerAccounts)
|
|
1690
|
+
.where(
|
|
1691
|
+
and(
|
|
1692
|
+
eq(providerAccounts.id, accountId),
|
|
1693
|
+
eq(providerAccounts.providerId, providerId),
|
|
1694
|
+
globalOrOwnedBy(providerAccounts.tenantId, tenantId),
|
|
1695
|
+
),
|
|
1696
|
+
)
|
|
1697
|
+
.limit(1);
|
|
1698
|
+
if (owned.length === 0) return false;
|
|
1699
|
+
return recoverAccount(this.db, accountId, "manual_operator_recovery");
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* One `UPDATE ... WHERE <cooldown predicate>` (D07/D08/D09). The predicate
|
|
1704
|
+
* is evaluated in SQL, so the whole provider is handled in one statement
|
|
1705
|
+
* without reading rows into the process. `cooldown_until` and
|
|
1706
|
+
* `model_cooldowns` are left untouched: they are the evidence the operator
|
|
1707
|
+
* sees on a disabled row until an explicit Recover clears them. A row that
|
|
1708
|
+
* is already `disabled` is not a target — those columns surviving the
|
|
1709
|
+
* status change would otherwise keep matching the predicate forever.
|
|
1710
|
+
*
|
|
1711
|
+
* Pool-as-account rows live in `provider_pool_accounts`, not this table, so
|
|
1712
|
+
* they are excluded by construction. Shared (`tenant_id IS NULL`) rows are
|
|
1713
|
+
* counted in `globalSkipped` but never written by a tenant; the global-admin
|
|
1714
|
+
* pair of routes owns those.
|
|
1715
|
+
*/
|
|
1716
|
+
async disableCooldowns(
|
|
1717
|
+
tenantId: string,
|
|
1718
|
+
providerId: string,
|
|
1719
|
+
): Promise<ProviderAccountBulkActionResult> {
|
|
1720
|
+
const nowIso = new Date().toISOString();
|
|
1721
|
+
const [globalRows, updatedRows] = await Promise.all([
|
|
1722
|
+
this.db
|
|
1723
|
+
.select({ id: providerAccounts.id })
|
|
1724
|
+
.from(providerAccounts)
|
|
1725
|
+
.where(
|
|
1726
|
+
and(
|
|
1727
|
+
eq(providerAccounts.providerId, providerId),
|
|
1728
|
+
isNull(providerAccounts.tenantId),
|
|
1729
|
+
this.liveCooldownPredicate(nowIso),
|
|
1730
|
+
),
|
|
1731
|
+
),
|
|
1732
|
+
this.db
|
|
1733
|
+
.update(providerAccounts)
|
|
1734
|
+
.set({ status: "disabled" })
|
|
1735
|
+
.where(
|
|
1736
|
+
and(
|
|
1737
|
+
eq(providerAccounts.providerId, providerId),
|
|
1738
|
+
eq(providerAccounts.tenantId, tenantId),
|
|
1739
|
+
this.liveCooldownPredicate(nowIso),
|
|
1740
|
+
),
|
|
1741
|
+
)
|
|
1742
|
+
.returning({ id: providerAccounts.id }),
|
|
1743
|
+
]);
|
|
1744
|
+
// Status is part of the cached credential's admission decision, so every
|
|
1745
|
+
// newly disabled id must be dropped here — not only on delete.
|
|
1746
|
+
invalidateCredentialCaches(updatedRows.map((row) => row.id));
|
|
1747
|
+
return { ok: true, updated: updatedRows.length, globalSkipped: globalRows.length };
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* One `DELETE ... WHERE <strict banned predicate> RETURNING id` (D12).
|
|
1752
|
+
* Credential and quota caches are dropped for the deleted ids in one pass
|
|
1753
|
+
* each, exactly like the existing batch-delete route. A delete is strictly
|
|
1754
|
+
* `status='disabled' AND last_error_category='policy_blocked_banned'`;
|
|
1755
|
+
* `auth_invalidated` is remediated by replacing the credential, not here.
|
|
1756
|
+
*/
|
|
1757
|
+
async deleteBanned(
|
|
1758
|
+
tenantId: string,
|
|
1759
|
+
providerId: string,
|
|
1760
|
+
): Promise<ProviderAccountBulkActionResult> {
|
|
1761
|
+
const globalRows = await this.db
|
|
1762
|
+
.select({ id: providerAccounts.id })
|
|
1763
|
+
.from(providerAccounts)
|
|
1764
|
+
.where(
|
|
1765
|
+
and(
|
|
1766
|
+
eq(providerAccounts.providerId, providerId),
|
|
1767
|
+
isNull(providerAccounts.tenantId),
|
|
1768
|
+
this.bannedPredicate(),
|
|
1769
|
+
),
|
|
1770
|
+
);
|
|
1771
|
+
const deletedRows = await this.db
|
|
1772
|
+
.delete(providerAccounts)
|
|
1773
|
+
.where(
|
|
1774
|
+
and(
|
|
1775
|
+
eq(providerAccounts.providerId, providerId),
|
|
1776
|
+
eq(providerAccounts.tenantId, tenantId),
|
|
1777
|
+
this.bannedPredicate(),
|
|
1778
|
+
),
|
|
1779
|
+
)
|
|
1780
|
+
.returning({ id: providerAccounts.id });
|
|
1781
|
+
const deletedIds = deletedRows.map((row) => row.id);
|
|
1782
|
+
invalidateCredentialCaches(deletedIds);
|
|
1783
|
+
await invalidateQuotaCaches(tenantId, deletedIds, this.redis);
|
|
1784
|
+
return {
|
|
1785
|
+
ok: true,
|
|
1786
|
+
updated: 0,
|
|
1787
|
+
deleted: deletedIds,
|
|
1788
|
+
missing: [],
|
|
1789
|
+
globalSkipped: globalRows.length,
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/** Strict banned definition shared by the tenant and global DELETE paths. */
|
|
1794
|
+
private bannedPredicate(): SQL {
|
|
1795
|
+
return and(
|
|
1796
|
+
eq(providerAccounts.status, "disabled"),
|
|
1797
|
+
eq(providerAccounts.lastErrorCategory, "policy_blocked_banned"),
|
|
1798
|
+
) as SQL;
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
/**
|
|
1802
|
+
* Not already disabled, and either `status='cooldown'`, `cooldown_until` is
|
|
1803
|
+
* still in the future, or `model_cooldowns` carries a live entry. Scoped to
|
|
1804
|
+
* the caller's own rows (the tenant path never touches `tenant_id IS NULL`
|
|
1805
|
+
* rows). The shared JSON subquery lives in the dialect module; only the
|
|
1806
|
+
* surrounding scoping is the store's.
|
|
1807
|
+
*
|
|
1808
|
+
* The disabled exclusion is load-bearing. Disable preserves the deadline
|
|
1809
|
+
* columns as evidence, so without it a row this action just wrote matches
|
|
1810
|
+
* the predicate again and the operator's count never drops to zero.
|
|
1811
|
+
*/
|
|
1812
|
+
private liveCooldownPredicate(nowIso: string): SQL {
|
|
1813
|
+
return and(
|
|
1814
|
+
ne(providerAccounts.status, "disabled"),
|
|
1815
|
+
or(
|
|
1816
|
+
eq(providerAccounts.status, "cooldown"),
|
|
1817
|
+
sql`${providerAccounts.cooldownUntil} > ${nowIso}`,
|
|
1818
|
+
liveModelCooldownExistsSql(this.db, nowIso),
|
|
1819
|
+
),
|
|
1820
|
+
) as SQL;
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
async syncModels(tenantId: string, providerId: string): Promise<{ synced: number }> {
|
|
1824
|
+
return this.probing.syncModels(tenantId, providerId);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
/** Read-only discovery; delegates to the same fetch path as sync (area 05). */
|
|
1828
|
+
async listDiscoveryCandidates(
|
|
1829
|
+
tenantId: string,
|
|
1830
|
+
providerId: string,
|
|
1831
|
+
): Promise<{
|
|
1832
|
+
readonly supported: boolean;
|
|
1833
|
+
readonly candidates: readonly DiscoverModelCandidate[];
|
|
1834
|
+
readonly accountLabel?: string;
|
|
1835
|
+
}> {
|
|
1836
|
+
const discovery = await this.probing.discoverModels(tenantId, providerId);
|
|
1837
|
+
if (!discovery.supported) return { supported: false, candidates: [] };
|
|
1838
|
+
const registered = await this.listModelsForTenant(tenantId, providerId);
|
|
1839
|
+
const ids = new Set(
|
|
1840
|
+
(registered.get(providerId) ?? []).map((entry) => entry.modelId),
|
|
1841
|
+
);
|
|
1842
|
+
// The picker exists to add models, so rows already on the roster are
|
|
1843
|
+
// excluded rather than listed disabled — a fully-registered catalog would
|
|
1844
|
+
// otherwise render an all-"Registered" list that reads as empty.
|
|
1845
|
+
const candidates: DiscoverModelCandidate[] = discovery.models
|
|
1846
|
+
.filter((definition) => !ids.has(definition.modelId))
|
|
1847
|
+
.map((definition) => ({
|
|
1848
|
+
modelId: definition.modelId,
|
|
1849
|
+
wireFamily: definition.wireFamily,
|
|
1850
|
+
serviceKind: definition.serviceKind ?? "llm",
|
|
1851
|
+
endpointPath: definition.endpointPath,
|
|
1852
|
+
contextLimit: definition.contextLimit,
|
|
1853
|
+
outputLimit: definition.outputLimit,
|
|
1854
|
+
modalities: definition.modalities,
|
|
1855
|
+
reasoning: definition.reasoning,
|
|
1856
|
+
toolCall: definition.toolCall,
|
|
1857
|
+
...(definition.freeTier === undefined ? {} : { freeTier: definition.freeTier }),
|
|
1858
|
+
...(definition.proOnly === undefined ? {} : { proOnly: definition.proOnly }),
|
|
1859
|
+
alreadyRegistered: false,
|
|
1860
|
+
}));
|
|
1861
|
+
return {
|
|
1862
|
+
supported: true,
|
|
1863
|
+
candidates,
|
|
1864
|
+
...(discovery.accountLabel === undefined ? {} : { accountLabel: discovery.accountLabel }),
|
|
1865
|
+
};
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
/**
|
|
1869
|
+
* Re-runs discovery on the server, then persists only ids the fresh roster
|
|
1870
|
+
* actually advertised (D28/D29/D30/D31). This is the anti-forgery gate: a
|
|
1871
|
+
* caller cannot attach a hand-built metadata object to an arbitrary id.
|
|
1872
|
+
*/
|
|
1873
|
+
async commitDiscoveredModels(
|
|
1874
|
+
tenantId: string,
|
|
1875
|
+
providerId: string,
|
|
1876
|
+
modelIds: readonly string[],
|
|
1877
|
+
): Promise<{
|
|
1878
|
+
readonly committed: readonly string[];
|
|
1879
|
+
readonly skipped: readonly string[];
|
|
1880
|
+
readonly synced: number;
|
|
1881
|
+
}> {
|
|
1882
|
+
const discovery = await this.probing.discoverModels(tenantId, providerId);
|
|
1883
|
+
if (!discovery.supported) {
|
|
1884
|
+
throw new ConsoleDomainError(
|
|
1885
|
+
"model_discovery_unsupported",
|
|
1886
|
+
400,
|
|
1887
|
+
`${providerId} publishes no model-listing endpoint; add the model manually`,
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
const byId = new Map(discovery.models.map((definition) => [definition.modelId, definition]));
|
|
1891
|
+
const wanted = [...new Set(modelIds)];
|
|
1892
|
+
const unknown = wanted.filter((id) => !byId.has(id));
|
|
1893
|
+
if (unknown.length > 0) {
|
|
1894
|
+
throw new ConsoleDomainError(
|
|
1895
|
+
"invalid_request",
|
|
1896
|
+
400,
|
|
1897
|
+
`Models are no longer in the provider's listing: ${unknown.join(", ")} (request {requestId}). Fetch the model list again 🪞`,
|
|
1898
|
+
);
|
|
1899
|
+
}
|
|
1900
|
+
const registered = await this.listModelsForTenant(tenantId, providerId);
|
|
1901
|
+
const existing = new Set(
|
|
1902
|
+
(registered.get(providerId) ?? []).map((entry) => entry.modelId),
|
|
1903
|
+
);
|
|
1904
|
+
const selected = wanted.filter((id) => !existing.has(id));
|
|
1905
|
+
const skipped = wanted.filter((id) => existing.has(id));
|
|
1906
|
+
if (selected.length === 0) return { committed: [], skipped, synced: 0 };
|
|
1907
|
+
const { synced } = await this.probing.commitDiscoveredModels(
|
|
1908
|
+
tenantId,
|
|
1909
|
+
providerId,
|
|
1910
|
+
selected,
|
|
1911
|
+
);
|
|
1912
|
+
return { committed: selected, skipped, synced };
|
|
1913
|
+
}
|
|
1914
|
+
}
|